meterstore 0.9.0

Hot/cold tiered store for metering time series — PostgreSQL for the recent window, Apache Iceberg for history.
Documentation
+++
title = "The CLI"
description = "meterstore init, check, create, status, archive, maintain, query, completeness and serve — the same library, without a program to write."
weight = 9
+++

```bash
cargo install meterstore --features cli
```

A thin front end over the same public API a service uses: every subcommand is one
or two library calls, and nothing here is unreachable from Rust.

| | |
|---|---|
| `init` `check` | Write a starter configuration; validate it, connecting to nothing |
| `create` | Both tiers, every declared table. Idempotent |
| `status` | Boundary, lag, write runway, health. Non-zero exit when unhealthy |
| `archive` `maintain` | One-shot for cron; foreground loop for a sidecar |
| `query` `explain` | SQL across both tiers, with the boundary it ran against |
| `completeness` | Which channels are short, and which delivered nothing |
| `snapshots` | What a settlement rerun can pin to |
| `erasures` | The erasure audit trail, deployment-wide |
| `serve` | Flight SQL and the Iceberg REST façade |
| `purge` | Destroy a table. No recovery path |

Every command takes `-c/--config` (or `METERSTORE_CONFIG`) and defaults to
`meterstore.toml` in the working directory.

## Getting to a working store

```bash
meterstore init            # a commented starter configuration
$EDITOR meterstore.toml
meterstore check           # full validation — no database needed
meterstore create          # both tiers, every declared table
meterstore status
```

`check` connects to nothing, which is what makes it a CI step: a file whose
`settlement_lag` is shorter than its `archival_step` fails there rather than
stranding corrections below the watermark in production.

## Keeping archival running

```bash
# The sidecar shape: a foreground loop, ctrl-c to stop.
meterstore maintain --interval 15m

# The cron shape: archive what is due, then exit.
meterstore archive --max-windows 8
```

Both are safe on **every** replica. One wins each table's archive lease and the
others report contention and stop, which is not a failure — see
[Operations](@/docs/operations.md#scheduling).

`maintain` stops after the cycle in flight rather than at the signal. For a cycle
mid-archival that is the difference between a clean stop and an orphaned
partition the next run has to reclaim.

Snapshot expiry is opt-in (`--expire-snapshots`), because a snapshot is what makes
a past settlement reproducible: how far back a settlement can be reproduced is a
compliance decision.

So is the other opt-in job. `--anonymise-after-years 3` runs the § 60 Abs. 6 MsbG
sweep on the same schedule, destroying the linkage of every subject whose readings
have passed the ceiling **in every table**:

```bash
meterstore maintain --anonymise-after-years 3 --anonymise-actor retention-job
```

Off by default and irreversible when on. The years are full calendar years after
the year of collection, not `now - 3 years`: the statutory clock starts at the
*Schluss des Kalenderjahres*, and the rolling spelling would erase a January value
a year early. Needs a table declaring `subject_column` and a `[privacy]` section
for the registry it resolves against — see
[Privacy and retention](@/docs/privacy.md).

## Asking what the store holds

```bash
meterstore query "SELECT meter_balancing_day(\"from\", sparte) AS day, SUM(value)
                  FROM readings
                  WHERE malo_id = '41373559241'
                  GROUP BY 1 ORDER BY 1"
```

```text
+------------+------------+
| day        | sum(value) |
+------------+------------+
| 2026-07-19 | 412.750000 |
| 2026-07-20 | 408.125000 |
+------------+------------+

boundary  readings_versions: 2026-08-18T00:00:00Z
tiers     cold + hot
warning   this answer includes the hot window, so it is only valid for now —
          those intervals are still being corrected
```

**The footer is the point.** A `SUM` alone cannot say whether it crossed the tier
boundary, and two identical queries a minute apart can read the same rows from
different tiers. Every result carries the boundary it was computed against and
whether it reproduces.

`--historical` reads the settled history alone, with no load on PostgreSQL, and
the footer then says the answer does not change. `--operational` reads the recent
window alone, with no Iceberg round trip.

`explain` shows the schema and the same provenance without running the statement —
which tiers a query would touch is usually the thing worth knowing about a slow
one.

A statement of `-` reads from standard input, so a long query can live in a file:

```bash
meterstore query - < settlement.sql
```

## Asking whether a month is complete

A `SUM` over an incomplete month returns a smaller number and no reason, so the
question has its own verb:

```bash
# The settlement period, named the way the market names one.
meterstore completeness --month 2026-06 --seen-since 30d
```

```text
TABLE                MALO          OBIS           SPARTE  RES      EXPECTED    ACTUAL   MISSING   SURPLUS  FIRST GAP     NOTE
readings_versions    41373559241   1-0:1.29.0     STROM   PT15M        2880      2880         0         0  —
readings_versions    56789012345   1-0:1.29.0     STROM   PT15M        2880      2784        96         0  2026-06-14
readings_versions    99887766554   1-0:1.29.0     STROM   PT15M        2880         0      2880         0  2026-06-01    delivered nothing in the range

3 channel(s) over [2026-05-31T22:00:00Z, 2026-06-30T22:00:00Z) — 2 incomplete, 1 silent, 2976 interval(s) missing
```

The expectation is the DST-aware calendar's, per balancing day: 92 intervals on
the spring day, 100 on the autumn one, and for gas both on the **Gastag** rather
than the Sunday. It covers **every day of the range**, so a channel that stopped
mid-month is short by every day after — and a report over a month that has not
finished reports the remainder as missing. Ask about periods that are over.
`--gaps-only` drops the complete rows.

**`--month YYYY-MM` is a Bilanzierungsmonat, not a calendar month.** The range
printed above starts at 22:00 UTC on 31 May because that is midnight in Berlin.
`--sparte GAS` cuts the same span at 06:00 local instead. Rows are always counted
against their *own* `sparte`; the flag decides where the **range** is cut, which
one value cannot do for two — so a table holding both is reported once per
commodity.

`--from`/`--to` take RFC 3339 instants for any other period, and `--malo`,
`--melo` and `--obis` narrow the report to one measuring point, one meter or one
channel — in the scan, so asking about one meter does not cost a scan of the
portfolio. Each is **parsed**: a mistyped identifier fails at the call rather
than narrowing to nothing, which on this report reads as *"everything is fine"*.

**`--seen-since` is the finding a range cannot make about itself.** A channel that
delivered nothing produces no rows to aggregate, so it is absent from the report
rather than reported as empty — unless a roster is drawn from an earlier window.
There is no default, because what a roster means is master data this crate does
not hold: too short and a meter read monthly looks decommissioned, too long and
every terminated measuring point is a standing finding. The narrowing flags apply
to the roster too, or every channel outside them would come back as silent.

This exits **zero** whatever it finds. A gap is a fact to triage; `status` is the
check that fails, because a stranded row means query results are *wrong* rather
than incomplete. For a monitoring check, read the JSON — it carries
`channels_reported`, `channels_incomplete`, `channels_silent`,
`channels_unmeasurable` and `intervals_missing` beside the rows:

```bash
meterstore completeness --month 2026-06 --format json \
  | jq -e '.channels_incomplete == 0 and .channels_unmeasurable == 0'
```

Both halves matter. A channel with no declared resolution has no expectation to
compare against, so it reports `complete` without anything having been checked —
a check on `channels_incomplete` alone passes a month nobody could judge.

More on what the numbers mean, and why `missing` is not `expected - actual`, in
[Completeness](@/docs/completeness.md).

## Machine output

`--format json` renders one document per invocation, with the provenance beside
the rows rather than under them:

```bash
meterstore status --format json | jq '.tables[] | select(.healthy | not)'
```

```json
{
  "rows": [{ "day": "2026-07-19", "sum(value)": "412.750000" }],
  "row_count": 1,
  "provenance": {
    "watermarks": [{ "table": "readings_versions", "watermark": "2026-08-18T00:00:00Z" }],
    "tiers_scanned": ["cold", "hot"],
    "reproducible": false
  }
}
```

Decimals are rendered as strings, not JSON numbers. Settlement is money, and a
double is not.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Failed, and retrying will not help — a bad statement, invalid configuration, a refused delivery |
| `75` | Failed transiently — the database was unreachable, a lock was not available. `EX_TEMPFAIL`, so a supervisor should try again |

`status` exits non-zero when a table is unhealthy, which is what makes it usable
as a monitoring check. The two ways a table stops working exit differently and the
message names which:

- **Rows stranded below the watermark.** Query results may be wrong. This is the
  alert.
- **No hot partition left ahead of the write frontier.** The next insert fails
  outright. Check that archival is running.

## Serving

```bash
meterstore serve --addr 127.0.0.1:50051 \
                 --catalog-addr 127.0.0.1:8181     # optional
```

Two surfaces, answering different questions.

**Flight SQL** carries the unified hot + cold view — the one surface an external
client cannot assemble for itself, because the hot tier is not in the Iceberg
catalogue.

**The catalogue façade** is a read-only Iceberg REST endpoint, and it is what a
**SQL-catalog** deployment needs so Spark, Trino, DuckDB and PyIceberg can read
the settled history straight from object storage — with this process in the
metadata path only, never the data path, so readers scale in parallel and it is
neither a bottleneck nor a single point of failure. A deployment already on a REST
catalogue needs none of it: point engines at the endpoint it already has, which is
why the flag is opt-in and Flight SQL's is not.

Analytics over settled history belong on the catalogue, read directly. Routing
them through Flight adds a proxy hop and serialises parallel reads through one
process.

One `ctrl-c` stops both. A server still answering metadata while its query surface
is down is worse than one that stopped, because a client cannot tell.

> **Unauthenticated, both of them.** This crate has no business deciding what kind
> of authentication a deployment uses, so the library hands back a tonic service
> and an axum router to wrap in your own interceptor and TLS, and the CLI binds
> them bare. Bind them to loopback or a trusted network, never to a public one.
> Read-only either way: Flight refuses every mutating call and every statement
> that is not a query, and the façade has no write path at all — an external
> writer appending through it would put rows in the cold tier without MeterStore
> knowing, and nothing downstream could detect it.

## Destroying a table

```bash
meterstore purge --table readings_versions --confirm readings_versions
```

The only operation in the crate that deletes stored readings — every partition,
the catalogue entry and the data files in object storage. There is no recovery
path, which is why the name has to be given twice.

## The erasure trail

```bash
meterstore erasures --limit 50
```

"We deleted it" is not evidence. Every erasure writes a row saying **when, why
and by whom** — and deliberately not *whose*, since the natural identifier is the
thing being destroyed. This is the report a regulator asks for.

The registry is deployment-wide, so the trail is one list rather than one per
table. A configuration whose tables declare no `subject_column` holds no mapping
at all, and says so rather than printing an empty list that reads as "nothing has
been erased".

## No `meterstore erase`

Reading the trail is the only thing the command line does with the subject
registry, and the omission is the same shape as the one below.

An Article 17 request usually reaches an application's own tables too — billing
periods, quality assessments, substitute-value logs — and those must succeed or
fail **together** with the mapping. A CLI invocation commits its own transaction
and cannot enclose them, so the failure mode is the worst kind: a subject
reported as erased whose derived rows survived. `SubjectRegistry::erase_in` takes
a transaction the caller owns, which is where that erasure belongs.
[Privacy and retention →](@/docs/privacy.md)

The duty that comes due on its own is a different matter, and the CLI does run
it: `meterstore maintain --anonymise-after-years 3`.

## No `meterstore append`

A reading arrives as an MSCONS message, an SMGW push or a CSV a utility exports
its own way, and each needs a mapping this crate has no opinion about — which OBIS
code, which network operator issued the version, which Messlokation. That is
[Writing readings](@/docs/writing.md); `check` is what the CLI offers it.