Somewhere for a number over time to land. "Did this improve?" is the whole question.
An agent asked "is this better than last month?" has nowhere to put the answer. Knowledge stores documents for retrieval, the graph stores facts about entities, and its re-assertion path keeps week one's number forever while confidence climbs. A metric series is neither: it is (orgId, orgScope, metric, at, dims) → value, the same measurement sampled repeatedly, with calendar bucketing and one correct period-over-period comparison. It ships as its own package, discovered only by hosts that install it, so a swarm that never asks the question is byte-identical.
When to reach for it — and when not
Reach for a metric series when the same measurement is sampled again and again and the question is "did it move?" — impressions this week versus last, average position before and after a change, tokens burned per day. You get calendar bucketing (hour · day · week · month, all UTC) and one period-over-period compare whose arithmetic is pinned so an agent cannot improvise it wrongly. If a number never changes, or you never diff two windows, you do not need this plane.
The other planes each hold a different kind of thing, and using the wrong one fails silently — a frozen number that reads exactly like a live one. Pick by what you are actually storing:
| If you need… | Reach for |
|---|---|
| a number that changes over time, compared window-to-window | @nightowlsdev/metrics — this plane |
| a durable fact about an entity — a name, a status, a relationship | @nightowlsdev/graph — but corroborate keeps the first value an edge ever saw, so it cannot hold a metric that moves |
| to retrieve a document by meaning | @nightowlsdev/knowledge — text for retrieval, not comparable numbers |
| engine run / usage telemetry | the engine's own StorageAdapter and UsageSink — metrics is deliberately not a StorageAdapter field, and the engine never touches these rows |
| high-cardinality event logs, dashboards, alerting, cross-scope roll-ups | a warehouse or TSDB — every one of these is explicitly out of scope for v1 here (see step 08) |
What a metric series is
A point is one observation, identified by five fields: orgId, the FR-055 orgScope, the metric name, the at instant, and the dims. Two points that differ only in one of those are two different series, never one. at is the business time, not the insert time, which is exactly why re-pulling last Tuesday overwrites last Tuesday instead of appending a second copy of it.
| Field | Rule |
|---|---|
| orgId | a UUID, lowercased before every persist and compare (the column collapses case, so the in-memory store must too) |
| at | RFC-3339 with an offset, millisecond precision at most — a finer instant THROWS rather than being truncated |
| value | finite — NaN/±Infinity are rejected, and the column is NOT NULL |
| dims | at most 8 string-valued keys, keys and values at most 64 code units each — a WIDTH cap, not a cardinality cap |
Cardinality is the failure mode, not volume. Eight dims of a million distinct values each is eight dims and a million series; the store will hold them and nothing here stops it. Aggregate before you record — a host should not keep every long-tail search query forever — and use deleteBefore as the retention knob.
A separate, opt-in package — not StorageAdapter.metrics
This is deliberately not a field on StorageAdapter and deliberately not a storage-supabase numbered migration. That package's plugin is ejected wholesale into every engine adopter, so a metrics table there would be mandatory for everyone, contradicting this plane's own additive/opt-in criterion. Instead it is a library-shaped package that owns its own metrics_000N series and is discovered by the CLI only from a host's installed deps. Install nothing, receive no DDL.
pnpm add @nightowlsdev/metrics pg
owl install metrics # ejects metrics_0001 into supabase/migrations/
supabase db push # you apply it, with your own toolingThe migration does not stand alone: nightowls.orgs (the FK target) and the nightowls schema itself come from @nightowlsdev/storage-supabase — apply its series through 0013_rename_schema first. The version is prefixed metrics_0001_… rather than a bare 0001_… precisely so the CLI, which merges every plugin's migrations and sorts by string, does not apply it before the schema it lives in exists.
Server-only, and that is enforced. RLS is ON with no policy and no client grant, and the migration explicitly REVOKE ALL … FROM authenticated, anon, public — because storage-supabase installs a default-privilege grant that would otherwise make the table born readable. Two consequences: the Pool you pass must be the table's owner or hold BYPASSRLS (an unprivileged role reads zero rows and writes nothing, which looks exactly like an empty store), and a client that needs these numbers gets them through your server, aggregated — never a row feed to the browser.
Construct the store, record a point
Library-shaped like @nightowlsdev/auth-sessions: you own the pg Pool (it is a peer dependency), the store queries exactly one table and never joins an engine one, so you may point it at a separate database entirely. There is no close() — the store holds no resource of its own, and the consumer that owns the Pool is the only thing that may end it.
import { createMetricStore } from "@nightowlsdev/metrics";
import { Pool } from "pg";
declare const orgId: string; // your org's UUID
// You own the Pool (pg is a peer dep). The table defaults to nightowls.metric_points; pass a
// table option to point the store at a database separate from the engine's.
const store = createMetricStore({ pool: new Pool() });
// One observation. The identity is (orgId, orgScope, metric, at, dims), so re-recording the same
// identity OVERWRITES — a backfill overlapping an earlier sweep never double-counts.
await store.record({
orgId,
metric: "seo.impressions",
at: "2026-03-02T00:00:00Z", // the BUSINESS time, millisecond precision at most
value: 1420,
dims: { page: "/pricing", device: "mobile" },
});
// Retention is the adopter's budget. deleteBefore removes every point of the metric strictly
// before the instant — across EVERY scope of the org (the one non-scope-selective call).
await store.deleteBefore(orgId, "seo.impressions", "2026-01-01T00:00:00Z");record returns { written: 1 } on success — an overwrite with an identical value still counts, because the row was touched. recordBatch is one transaction: any invalid point, or any hash collision, rolls the whole batch back, and in-batch duplicates resolve last-input-wins.
The canonical key
The identity of the dims is a hash the store computes on the way in: "v1:" + sha256(canonicalMetricDims(dims)). It is deliberately not a Postgres generated column — jsonb's text serialization does not preserve authored key order and is not this algorithm, so a hash computed in the database and a hash computed in JS would disagree the moment a host wrote through some other path. Computing it in one place, the same way for both the Postgres and in-memory stores, makes the parity structural rather than hoped-for.
undefined, null and {} all canonicalize to one identity — omitted and empty dims are the same series, not two — and key order is irrelevant by construction, which is what lets a jsonb round-trip come back as the series it went in as. An explicitly-present null is rejected on both write and read, because it once meant empty-dims on the way in and no-filter on the way out, and one value cannot mean two things one hop apart. Because a hash can collide, every exact-dims read predicates on the hash and jsonb equality, and a colliding write is refused outright rather than silently fusing two series into one. canonicalMetricDims and metricDimsHash are exported, so a host can pre-compute a key without a round-trip.
Read it back: query and compare
query has two modes off a single field: supply a bucket and you get aggregated calendar buckets, omit it and you get raw points. groupBy requires a bucket (aggregation needs one); supplying it in raw mode throws. compare is the pinned period-over-period answer, the single most-asked question and the easiest to get subtly wrong.
import { createMetricStore } from "@nightowlsdev/metrics";
import { Pool } from "pg";
declare const orgId: string;
const store = createMetricStore({ pool: new Pool() });
// Bucketed read: daily sums, grouped by one dim. Omit the bucket for raw points instead.
const series = await store.query({
orgId,
metric: "seo.impressions",
window: { from: "2026-03-01T00:00:00Z", to: "2026-04-01T00:00:00Z" },
dims: { device: "mobile" }, // EXACT-match filter over the identity dims, never a subset match
groupBy: ["page"],
bucket: { step: "day", aggregate: "sum" },
});
// Period-over-period, fully pinned. previous is compared AS SUPPLIED, never shifted by the cutoff.
const cmp = await store.compare({
orgId,
metric: "seo.impressions",
current: { from: "2026-03-01T00:00:00Z", to: "2026-04-01T00:00:00Z" },
previous: { from: "2026-02-01T00:00:00Z", to: "2026-03-01T00:00:00Z" },
step: "day",
aggregate: "sum",
dataLag: "PT48H", // the upstream lag: Search Console does not report today
});
// totals come from RAW observations, never a bucket-of-buckets; direction reads off totals.abs.
const { totals, direction, effectiveCutoff } = cmp;| Knob | Values |
|---|---|
| step | hour · day · week (ISO Monday, 00:00 UTC) · month (calendar). All UTC. |
| aggregate | avg · sum · min · max · count · last (greatest at wins — the one that matters for rank-style metrics) |
Why compare is in the contract. It pins the arithmetic that agents otherwise improvise wrongly: windows are half-open [from, to); the current window is truncated to the effectiveCutoff = asOf − dataLag while previous is compared as supplied (shifting it would overlap the current window); a bucket carries partial: true when its calendar interval is not fully inside the window; value: null means no observation and an observed zero is 0; totals are computed over raw observations, never a bucket-of-buckets; and coverageEqual goes false — nulling totals.abs/pct — whenever the effective lengths differ or any bucket is partial, so an apples-to-oranges delta is refused rather than reported.
orgScope is an identity selector, not FR-055 visibility
This is a deliberate divergence from FR-055. On reads, undefined ≡ null ≡ the org-wide series only; a string selects exactly that one scope. There is no unrestricted all-scopes read — a host loops its scopes. FR-055's "undefined-means-unrestricted" is a visibility filter over enumerable rows, the right default there and the wrong one here: an aggregation that silently mixed departments into one number is the exact corruption a metrics store exists to prevent.
The same rule governs compare. deleteBefore is the one exception: it deletes every scope of the metric, because retention is org-level housekeeping and a per-scope sweep would leave the org's other scopes silently unpruned.
The in-memory store is a shipped export
createInMemoryMetricStore() satisfies the same MetricStore contract exactly: same validator, same identity rules, same collision behaviour, same atomicity, same reducer. It is a real store, not a stub — the package's conformance suite runs unchanged against it and against Postgres and asserts identical answers. compare's window arithmetic is the part that is hardest to exercise, and requiring a live database to test it means it gets tested once and then never again. Reach for it in tests and for exercising the math without provisioning anything.
Not in v1. Engine-emitted metrics (a metricsSink mirroring UsageSink is the future seam, not a StorageAdapter field), dashboards and React surfaces, alerting, downsampling, and cross-scope aggregate reads. The engine never reads or writes these rows; the consumers are agents and hosts.
Common pitfalls
Four ways the store behaves exactly as designed but not as a first-time reader expects — each a silent symptom rather than a thrown error.
| Symptom | Cause & fix |
|---|---|
| Every read comes back empty; writes seem to vanish | The Pool is not the table's owner and lacks BYPASSRLS. RLS is on with no policy, so an unprivileged role reads zero rows and writes nothing — indistinguishable from a fresh store. Pass a postgres/service connection. |
| A value of null where you expected a number | In compare, null means no observation (an observed zero is 0). In query it never means empty — only buckets holding at least one observation are emitted, so a null there means the sum/avg overflowed a finite double. A non-finite result always surfaces as null, never Infinity/NaN or a thrown SQL error. |
| Every record throws after you moved the table | A custom table must keep the shipped unique-constraint name metric_points_identity — the upsert names it as the on conflict arbiter. Rename the constraint and there is nothing to conflict on. |
| dataLag: "P1M" (one month) is rejected | The duration grammar is P[nW][nD][T[nH][nM][nS]] — whole non-negative integers, no year or month designators. "One month" has no fixed length (28 / 30 / 31 days), and a lag that moved under the reader would silently reshape the comparison. Express it in weeks or days (one minute is PT1M, not P1M). |
And the one that is volume-shaped, not a bug. Cardinality — not the count of observations — is what runs the table away from you (the width-vs-cardinality note in step 02). Aggregate before you record, and let deleteBefore hold the line on retention.
API reference
- createMetricStore({ pool, table? }) — the Postgres store. table defaults to nightowls.metric_points and is interpolated directly into the SQL, so it must be a trusted config value.
- createInMemoryMetricStore() — the same contract, in memory. A shipped export, not a fixture.
- MetricStore: record / recordBatch / query / compare / deleteBefore. No close() — the store owns no resource.
- canonicalMetricDims / metricDimsHash / DIMS_HASH_PREFIX — the one canonicalization and the hash derived from it.
- METRICS_MIGRATIONS / M_METRICS_0001_METRIC_POINTS / METRIC_POINTS_MIGRATION_SQL — the migration series and its raw DDL, so you can create the table wherever you like.
- nightOwlsPlugin — the CLI adapter manifest (owl install metrics, owl metrics info). Data-only; it never runs DDL.
Where to go next
The SEO crew
The consumer this plane was surfaced by — an agent that has to answer "did position improve?" over the Search Console window.
Sub-org scopes
orgScope partitions a series by department — and why it is an identity selector here, not a visibility filter.
Knowledge & tools
The plane for documents and facts — and why neither of them can hold a number that changes.
Building on Night Owls? See the source on GitHub.