# TinyStore 0.2.0

TinyStore 0.2.0 came out on 26 October 2026. Records and metrics now accept
a retried write without writing it twice, metrics takes a large exporter's
whole scrape in one write, and aggregates can follow a time zone's calendar.
Jobs can limit each group to a rate, and a Rust program can read its
settings without a store.

- **Records**
  - [Events with an id](#events-with-an-id): a retried append writes its event once.
- **Metrics**
  - [Ingests that are safe to retry](#ingests-that-are-safe-to-retry): samples sent again after packing change nothing.
  - [A whole scrape in one write](#a-whole-scrape-in-one-write): up to 100,000 samples from every SDK, all or nothing.
  - [Help, units and quoted names](#help-units-and-quoted-names): everything a Prometheus 3 exporter writes.
  - [Days in a time zone](#days-in-a-time-zone): aggregates by a local day, week or month.
- **Jobs**
  - [A rate for each group of jobs](#a-rate-for-each-group-of-jobs): one message a second per chat, thirty in all.
- **Config**
  - [Settings without a store](#settings-without-a-store): `config::load` for a Rust program that keeps nothing.

> \[!WARNING]
> **One change can break your build**
> `append` now returns whether it wrote the event. Only Go needs a change,
> [the others](#changed-names) still build:
>
> ```diff
> - err := events.Append(ctx, "push", fields)
> + _, err := events.Append(ctx, "push", fields)
> ```

## Events with an id

A job step runs at least once, so a worker that crashes after `append` sends
the same event again on its next attempt. Give the event an id, such as a
webhook's delivery id, and the second append writes nothing:

```ts mark=3
const deploys = store.events('deploys')
await deploys.append('push', { sha }, { id: delivery }) // true
await deploys.append('push', { sha }, { id: delivery }) // false: written already
```

```python mark=3
deploys = store.events("deploys")
await deploys.append("push", {"sha": sha}, id=delivery)  # True
await deploys.append("push", {"sha": sha}, id=delivery)  # False: written already
```

```go mark=3
deploys, err := records.OpenEvents(store, "deploys")
added, err := deploys.Append(ctx, "push", records.Fields{"sha": sha}, records.ID(delivery)) // true
added, err = deploys.Append(ctx, "push", records.Fields{"sha": sha}, records.ID(delivery))  // false: written already
```

```rust mark=3
let deploys = store.events("deploys").open()?;
let added: bool = deploys.event("push").id(&delivery).append(&push)?; // true
let added: bool = deploys.event("push").id(&delivery).append(&push)?; // false: written already
```

The stream keeps an id as long as a read can still return its event, and a
read returns the id with the event. An event without an id is always
written. [Events](https://tinyshed.org/tinystore/docs/guides/records/events#an-append-sent-again) has the
details.

## Ingests that are safe to retry

The same goes for metrics. A collector's step that sends the same samples
again changes nothing, even after the store has packed those samples into a
block:

```ts
await store.metrics.ingest(revenue) // three hours of samples, read from a database
await store.metrics.ingest(revenue) // the step's next attempt: nothing changes
```

```python
await store.metrics.ingest(revenue)  # three hours of samples, read from a database
await store.metrics.ingest(revenue)  # the step's next attempt: nothing changes
```

```go
err := metrics.Ingest(ctx, store, revenue) // three hours of samples, read from a database
err = metrics.Ingest(ctx, store, revenue)  // the step's next attempt: nothing changes
```

```rust
let stats = store.metrics()?;
stats.ingest(&[revenue.clone()])?; // three hours of samples, read from a database
stats.ingest(&[revenue])?; // the step's next attempt: nothing changes
```

A sample counts as sent again when its time and its value's bits match the
packed one. A different value behind the packed part still fails with a too
old error, which now says when the sample was and up to when its series is
packed. [Ingesting](https://tinyshed.org/tinystore/docs/guides/metrics/ingesting#an-ingest-sent-again) has
the details.

## A whole scrape in one write

kube-state-metrics and cAdvisor on a busy cluster serve tens of thousands of
series. One `ingestPrometheus` call now takes a text of up to 100,000
samples and writes it whole, or writes nothing and says why:

```ts
const text = await (await fetch('http://ksm:8080/metrics')).text()
await store.metrics.ingestPrometheus(text, { labels: { instance: 'ksm' } })
```

```python
text = httpx.get("http://ksm:8080/metrics").text
await store.metrics.ingest_prometheus(text, labels={"instance": "ksm"})
```

```go
resp, err := http.Get("http://ksm:8080/metrics")
body, err := io.ReadAll(resp.Body)
err = metrics.IngestPrometheus(ctx, store, body, metrics.Labels{"instance": "ksm"})
```

```rust
let stats = store.metrics()?;
stats.prometheus().label("instance", "ksm").ingest(&body)?; // body: the text of http://ksm:8080/metrics
```

- Bun, Node, Python and Go send a text larger than one message, 2 MiB, in
  pieces. The store writes nothing until the last piece arrives, so a
  dropped connection leaves nothing half written.
- A store with a memory budget reserves the text's memory before it reads a
  line, about half a KiB per series, and refuses a text that cannot fit
  instead of running out of memory.
- While a large text is written, other writes of metrics wait behind it.

## Help, units and quoted names

A family's `# HELP` and `# UNIT` now describe the names it writes, and a
name in quotes keeps its dots, as Prometheus 3 and OpenTelemetry write it:

```text
# HELP rpc_seconds How long a call took.
# UNIT rpc_seconds seconds
# TYPE rpc_seconds histogram
rpc_seconds_sum 41.7
{"http.server.request.duration", "url.scheme"="https"} 0.25
```

```ts
await store.metrics.description('rpc_seconds_sum') // { unit: 'seconds', help: 'How long a call took.' }
```

```python
await store.metrics.description("rpc_seconds_sum")  # Description(unit="seconds", help="How long a call took.")
```

```go
described, err := metrics.DescriptionOf(ctx, store, "rpc_seconds_sum") // {Unit: "seconds", Help: "How long a call took."}
```

```rust
let described = stats.description("rpc_seconds_sum")?; // unit "seconds", help "How long a call took."
```

The unit goes only to the names whose values are in it: `rpc_seconds_sum`
takes `seconds`, while `_bucket` and `_count`, which count observations,
take none. What `describe` set in your code always stays.

## Days in a time zone

A day of `every: '1d'` runs from midnight to midnight in UTC. A shop in
Moscow counts its orders from its own midnight, so ask for buckets of the
calendar instead, with the zone's IANA name:

```ts
const days = await store.metrics.aggregate({ name: 'orders', since: '30d', op: 'sum', calendar: 'day', timeZone: 'Europe/Moscow' })
```

```python
days = await store.metrics.aggregate(name="orders", since="30d", op="sum", calendar="day", time_zone="Europe/Moscow")
```

```go
days, err := metrics.Aggregate(ctx, store, metrics.Query{Name: "orders", Since: 30 * 24 * time.Hour, Op: metrics.Sum,
	Calendar: tinystore.Day, TimeZone: "Europe/Moscow"})
```

```rust
let days = stats
    .query()
    .name("orders")
    .since(Duration::from_hours(30 * 24))
    .calendar(Calendar::Day, "Europe/Moscow")
    .aggregate(Op::Sum)?;
```

`calendar` is `day`, `week` or `month`, and records' aggregates take it too.
A day is 23 or 25 hours long on the days the clock moves, and a `rate`
divides by the seconds its own bucket has.
[Aggregates](https://tinyshed.org/tinystore/docs/guides/metrics/aggregates#days-in-a-time-zone) has the
details.

## A rate for each group of jobs

Telegram lets a bot send thirty messages a second in all, and one a second
to each chat. A queue can now say both:

```ts
const telegram = store.queue<Message>('telegram', { rate: '30/s', groupRate: '1/s' })
await telegram.add({ chat, text }, { group: `chat:${chat}` })
```

```python
telegram = store.queue("telegram", Message, rate="30/s", group_rate="1/s")
await telegram.add(Message(chat=chat, text=text), group=f"chat:{chat}")
```

```go
telegram, err := jobs.OpenQueue[Message](store, "telegram", jobs.Rate(30, time.Second), jobs.GroupRate(1, time.Second))
_, err = telegram.Add(ctx, Message{Chat: chat, Text: text}, jobs.Group(fmt.Sprintf("chat:%d", chat)))
```

```rust
let telegram = store.queue::<Message>("telegram").rate(30, Duration::from_secs(1)).group_rate(1, Duration::from_secs(1)).open()?;
telegram.group(format!("chat:{chat}")).add(&Message { chat, text })?;
```

A job whose group has used its rate waits for its group's next turn, out of
the way of every other group's jobs. A busy chat never holds back a quiet
one. [Concurrency and rate](https://tinyshed.org/tinystore/docs/guides/jobs/concurrency) has the details.

## Settings without a store

A Rust program that only reads its settings at start, such as an agent that
runs beside someone else's database, can load them without opening a store.
Nothing is written to disk:

```rust
let agent = tinystore::config::load("agent", Agent::default())
    .file(File::optional("agent.toml"))
    .env(Env::prefix("AGENT"))
    .open()?;
```

Only Rust has it so far. Bun, Node, Python and Go reach the config engine
through a store. [Layers](https://tinyshed.org/tinystore/docs/guides/config/layers#without-a-store) has the
details.

## Changed names

`append` returns whether it wrote the event, and a list's `append` returns
how many events it wrote:

```ts
// 0.1
await deploys.append('push', { sha })
// 0.2
const added = await deploys.append('push', { sha }) // true when it wrote the event
const written = await deploys.append(events) // how many it wrote
```

```python
# 0.1
await deploys.append("push", {"sha": sha})
# 0.2
added = await deploys.append("push", {"sha": sha})  # True when it wrote the event
written = await deploys.append(events)  # how many it wrote
```

```go
// 0.1
err := deploys.Append(ctx, "push", records.Fields{"sha": sha})
// 0.2
added, err := deploys.Append(ctx, "push", records.Fields{"sha": sha}) // true when it wrote the event
written, err := deploys.AppendMany(ctx, events)                       // how many it wrote
```

```rust
// 0.1: Result<()>
deploys.append("push", &push)?;
// 0.2
let added: bool = deploys.append("push", &push)?; // true when it wrote the event
let written: usize = deploys.append_all(events)?; // how many it wrote
```

Code that ignores the result still builds in Bun, Node, Python and Rust. Only
Go's two return values need a change.

## Fixes

- A Prometheus text reserves the memory its series hold before the store
  reads it. Before, a store with a memory budget counted only a small part
  of what a large text held.
- A job of a group with a rate no longer takes a handler that another job
  could use, and a claim that fails gives back the starts it counted.
- An event's id is let go once no part of the stream can return the event,
  and also when a drop of damaged events removes it. `tinystore logs` prints
  the id.
- An empty quoted name in an exposition fails at its line, and the samples
  counter says that it counts the samples sent again too.
- A Rust program's awaited config that was loaded without a store answers
  as the blocking one does.

## Faster

- A first scrape of a large exporter registers its new series faster.
- A job's add, claim and settlement each run fewer statements.

## Install

```sh for=bun
bun add @tinyshed/tinystore@0.2.0
npm install @tinyshed/tinystore@0.2.0
```

```sh for=python
pip install tinyshed-tinystore==0.2.0
```

```sh for=go
go get tinyshed.org/tinystore@v0.2.0
```

```sh for=rust
cargo add tinystore@0.2.0
cargo install tinystore-cli@0.2.0
```

A store that 0.1 wrote opens in 0.2.0 as it is: records adds a table of
event ids the first time it opens. If your program reaches a server, update
the server with the SDK, because a 0.1 server knows neither event ids nor a
text sent in pieces. The server image is `ghcr.io/tinyshed/tinystore:0.2.0`.

Every commit since 0.1.1 is in the
[GitHub release](https://github.com/tinyshed/tinystore/releases/tag/v0.2.0).
