openvibers/openvibe.sdk Commits

Add a shared usage-reading reporter to openvibe-sdk/usage (#16)

OP OpenVibers committed 5 Oct 2026 · 56aeba8a55 · 11 files, +505 −14

* Add a shared usage-reading reporter to openvibe-sdk/usage

**Files changed**

- `src/usage.js`: added `createUsageReporter({ db, service, source, table='usage_outbox', billingUrl, tokenClient, … })` — `key()`/`sample()` derive readings for one service, `record(t, reading)` validates via `validateUsageSample` and inserts an idempotency-keyed (`event_id = idempotency_key`) row inside the caller's transaction (`ON CONFLICT DO NOTHING`), and the relay is `createPgOutbox` (from `./outbox`) pointed at Billing's `/api/v1/usage` with a `tokenClient.getToken({audience})`. Transient failures retry with backoff; only 400/409/413/422 mark a row rejected, kept with its error. Without `billingUrl`/`tokenClient` readings still queue, relay off. Also softened the header's "no dependency at require time" claim since it now requires `./outbox`.
- `test/usage-reporter.test.js` (new): key derivation, replay refusal (same key stored once; wrong-body replay refused), transient retry/never-dropped, 422 rejected-and-kept, argument validation, disabled-relay queueing — on PGlite with a stub fetch/token client.
- `types/usage.d.ts`: declared `UsageTokenClient`, `UsageReporterOptions`, `UsageReporter`, `createUsageReporter`.
- `esm/usage.mjs`: regenerated (`node scripts/esm.js`), now exports `createUsageReporter`.
- `package.json`, `package-lock.json`, `src/core/index.js` (`SDK_VERSION`), `STATUS.json`, `README.md` status/install lines: bumped 0.31.0 → 0.32.0.
- `CHANGELOG.md`: new top `## 0.32.0 (2026-10-05)` section documenting the reporter.
- `browser/openvibe-sdk.mjs`: regenerated (`node scripts/browser-bundle.js`) — the bundle embeds the version; `src/usage.js` is not browser-reachable (`browser: null`).

The `./usage` subpath and its types already existed in `package.json` exports, so nothing needed adding there; `index.js` already re-exports the usage namespace, so `createUsageReporter` is reachable at the top level.

**Tests**

`ov test test/usage-reporter.test.js test/usage.test.js test/esm.test.js test/types.test.js test/version.test.js test/bundle.test.js test/pack.test.js` → **7 passed** (14 s). (Also ran `test/usage-reporter.test.js` alone first: 1 passed.)

**Not done / notes**

- No subpath addition was needed (already present), so `pack.test.js`'s `subpaths.length === 41` is unchanged.
- Did not commit, push, deploy, or install packages, per instructions.

* Usage reporter: refuse every 4xx but 401/403/404/408/425/429, invalidate the token on 401, document billing only real traffic (review)

All four tests pass and no commit was made. Summary of changes:

**Behavior fixes**
- `src/usage.js:95` — replaced `REFUSED` (400/409/413/422) with `RETRYABLE_4XX = {401, 403, 404, 408, 425, 429}` matching OpenVibe.Events' `server/billing.js`; any other 4xx (402/410/415 included) is now refused, while those six plus 5xx/network stay pending. (The events outbox's own set differs on 403/404/409, so a local set is used rather than `outbox.isPermanent`; noted at `src/usage.js:92-94`.)
- `src/usage.js:189` — `err.status = (4xx && !RETRYABLE_4XX.has(status)) ? 422 : 503`, preserving the real status in the message.
- `src/usage.js:184` — a 401 now calls `tokenClient.invalidate?.({ audience })` before the retry, as Events does.

**Billing-responsibility guard + docs**
- `src/usage.js:97,114-115,126,150-151,160-161` — new opt-in `requireProject` (default false) that makes `record()` refuse a reading whose `project` isn't `prj_…`; documented in the file header and `record()` JSDoc.
- `README.md:185` — documents `createUsageReporter` and that it bills whatever reading it is given (never first-party/sandbox; `requireProject: true` to refuse).
- `types/usage.d.ts:15-18,35-36,59-60` and `:69-74` — mirrored the `invalidate` capability, `requireProject` option, record() note, and corrected status list.
- `CHANGELOG.md:22-29` — corrected the 0.32.0 entry's status set and noted the 401 invalidation/guard.

**Tests** (`test/usage-reporter.test.js`)
- `:172` full status matrix: 401/403/404/408/425/429/500 stay pending; 400/402/409/410/413/415/422 are refused.
- `:191` network error (fetch throws) stays pending.
- `:198` 401 invalidates the cached token and the retry mints a fresh one.
- `:213` `requireProject` refuses a projectless reading, accepts a valid `prj_…` one.
- `:226` `start()` schedules one unref'd relay timer and `stop()` clears it.

**Artifacts:** `node scripts/esm.js` and `node scripts/browser-bundle.js` ran; both produced no diff (`src/usage.js` isn't reachable from `browser.js`, and its exports are unchanged).

**Test results:** `ov test test/usage-reporter.test.js test/version.test.js test/bundle.test.js test/pack.test.js` → **4 passed in 44 s** (bundle 0.2 s, pack 1.9 s, usage-reporter 43.5 s, version 0.1 s).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9ff1816..28e77b5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -7,6 +7,28 @@ release may change an API and says so here.
++## 0.32.0 (2026-10-05)
++
++- **`openvibe-sdk/usage` gains `createUsageReporter` (plan T1 step 7):** the shared reporter Tools, AI, Events and
++ Bot each rewrote. `createUsageReporter({ db, service, source, table = 'usage_outbox', billingUrl, tokenClient,
++ audience, fetchImpl, timeoutMs, intervalMs, batchSize, now, log })` owns one service's readings: `key(...parts)`
++ is `usageKey(service, …)`, `sample(fields)` fills in the reporter's `service` and `source` and defaults `id` to
++ the idempotency key, and `record(t, reading)` validates the reading against `platform.usage-sample@1`
++ (via `validateUsageSample`, so a missing openvibe-contracts never blocks the queue) and inserts it
++ idempotency-keyed in the outbox table inside the caller's transaction (`db.tx`'s handle; a replayed key changes
++ nothing). The relay is `createPgOutbox` pointed at Billing's `billing.usage.record` (`POST /api/v1/usage`, one
++ reading per request): `start`/`stop`/`kick`/`flush`/`prune` plus `pending`/`rejected`, and it uses the given
++ token client (`createServiceTokenClient`, audience `openvibe.billing`). A reading is never dropped — a relay
++ that cannot reach Billing (down, no grant yet, a missing route, a timeout or a 401/403/404/408/425/429/5xx)
++ retries with backoff across restarts — and only Billing refusing the reading itself (any other 4xx: 400, 402,
++ 409, 410, 413, 415, 422) marks a row rejected: kept with its error and never sent again, so nothing is billed
++ twice; a 401 drops the cached token first. The reporter bills whatever reading it is given, so callers must not
++ record first-party or sandbox traffic: `requireProject: true` makes `record()` refuse a reading without a
++ `prj_…` project. Without `billingUrl` or `tokenClient` readings still queue and
++ only the relay is off. Re-exported from the top-level entry and declared in `types/usage.d.ts` (the `./usage`
++ subpath already existed); `outboxSchema(table)` stays the migration DDL. New unit test for key derivation,
++ replay refusal and the retry/reject relay (the full status matrix, 401 invalidation and the timer stop).
++
## 0.31.1 (2026-10-05)
- **`createTestDb` in pg mode no longer keeps a test process alive (`openvibe-sdk/testing`, patch):** the lease
ddiff --git a/README.md b/README.md
iindex 1f1bb88..1b28a7c 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.31.1 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.32.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.31.1
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -182,7 +182,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRe.Stream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
--| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `openvibe-sdk/govern` builds its `onUsage` record with it |
++| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute |
| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card |
ddiff --git a/STATUS.json b/STATUS.json
iindex ec3a44a..314339e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.31.1",
++ "version": "0.32.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -26,6 +26,7 @@
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
++ "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js"
],
"updated": "2026-10-05"
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex ba40249..cc84c59 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.31.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.32.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.31.1';
++const SDK_VERSION = '0.32.0';
module.exports = {
createClient,
ddiff --git a/esm/usage.mjs b/esm/usage.mjs
iindex aa8c2ab..344a02b 100644
---- a/esm/usage.mjs
++++ b/esm/usage.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/usage.js';
export const {
-- usageSample, usageKey, validateUsageSample,
++ usageSample, usageKey, validateUsageSample, createUsageReporter,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9b7a048..0da7840 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.31.1",
++ "version": "0.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.31.1",
++ "version": "0.32.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 175c5f4..340bfe1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.31.1",
++ "version": "0.32.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 3820879..f44a4a5 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.31.1';
++const SDK_VERSION = '0.32.0';
module.exports = {
createClient,
ddiff --git a/src/usage.js b/src/usage.js
iindex 72c44c9..37760a5 100644
---- a/src/usage.js
++++ b/src/usage.js
@@@ -1,14 +1,37 @@
'use strict';
/**
* openvibe-sdk/usage — platform.usage-sample@1, one metered usage reading with retry-safe attribution
-- * (T1 Universal Fabric). Pure construction and validation, no I/O and no dependency at require time:
-- * `openvibe-contracts` is required lazily by validateUsageSample, the way src/service re-exports its packages.
++ * (T1 Universal Fabric). Construction and validation are pure and dependency-free: `openvibe-contracts`
++ * is required lazily by validateUsageSample, the way src/service re-exports its packages. The reporter
++ * below (server only, browser: null) does no I/O of its own either — it uses the db handle the caller
++ * passes and relays with src/outbox's createPgOutbox.
*
* const { usageSample, usageKey, validateUsageSample } = require('openvibe-sdk/usage');
* const record = usageSample({ id, idempotency_key: usageKey('media', 'delivery', 1), service: 'media',
* operation: 'deliver', quantity: 1.5, unit: 'GiB' }); // at and source default
* validateUsageSample(record); // { ok, errors }
*
++ * createUsageReporter() is the shared step-7 recipe Tools, AI, Events and Bot each rewrote: one service's
++ * readings, built and validated, queued idempotency-keyed in an outbox table inside the caller's
++ * transaction, and relayed to Billing's billing.usage.record (POST /api/v1/usage, one reading per
++ * request) with createPgOutbox, so a reading is never dropped: a relay that cannot reach Billing — no
++ * grant yet, a missing route, a timeout, backpressure or a 401/403/404/408/425/429 — retries with
++ * backoff across restarts, and only Billing refusing the reading itself (any other 4xx: 400, 402, 409,
++ * 410, 413, 415, 422 …) marks a row rejected — kept with its error and never sent again, so nothing is
++ * billed twice. A 401 drops the cached service token so the retry mints a new one. With no billingUrl or
++ * tokenClient the reporter still queues readings; only the relay is off (they wait for a process with both).
++ * The reporter bills whatever reading it is given: it cannot tell production traffic from first-party or
++ * sandbox traffic, so callers must only record billable production readings; `requireProject: true` makes
++ * record() refuse a reading whose `project` is not a `prj_…` id.
++ *
++ * const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker',
++ * billingUrl, tokenClient: createServiceTokenClient({ clientId: 'run', clientSecret }) });
++ * await reporter.ensureSchema(); // services put it in a migration instead
++ * const reading = reporter.sample({ idempotency_key: reporter.key('job', jobId, n), operation: 'function.invoke',
++ * quantity: 1, unit: 's', resource: jobId, node: nodeId });
++ * await db.tx(async (t) => { await reporter.record(t, reading); }); // idempotent: a replay changes nothing
++ * reporter.start();
++ *
* No money fields are invented: cost_estimate, free_allowance_used and vibes_charged ride along only when
* the caller passes them (rating is Billing's). openvibe-sdk/govern builds its onUsage record with usageSample().
*/
@@@ -59,4 +82,135 @@ function validateUsageSample(record) {
return { ok: Boolean(r.valid), errors: r.errors || [] };
}
--module.exports = { usageSample, usageKey, validateUsageSample };
++// ── the shared usage reporter (server-only): queue and relay one service's readings ───────────────
++
++const { createPgOutbox, outboxSchema } = require('./outbox');
++
++const TABLE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
++const STATUSES = 'sent_at IS NULL AND rejected_at IS NULL';
++// Billing's 4xx answers about the credentials, the grant, the address or pressure — not the reading: those
++// (with 5xx and network errors) stay pending with backoff; any other 4xx is Billing refusing the reading.
++// OpenVibe.Events' server/billing.js uses this same set. The events outbox's own RETRYABLE_4XX differs (409
++// is a duplicate to retry there, and 403/404 are permanent): it is for envelopes, not for a billed reading.
++const RETRYABLE_4XX = new Set([401, 403, 404, 408, 425, 429]);
++// A production project id, the way openvibe-contracts/Events spell it (ULID alphabet, no I/L/O/U).
++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/**
++ * The reporter for one service's readings. It never invents a reading: `sample()` fills in the
++ * reporter's service and source, and `record()` writes an already-built one. `record()` must run inside
++ * the caller's transaction (`db.tx`'s handle), so a reading exists if and only if the change that
++ * produced it committed; a repeated idempotency_key changes nothing (the outbox keeps the first).
++ *
++ * @param {object} o
++ * @param {object} o.db openvibe-sdk/db handle (server); the outbox table lives in the same database
++ * @param {string} o.service the Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'
++ * @param {string} o.source the sample's `source`, e.g. 'openvibe-node.worker', 'ai.runs'
++ * @param {string} [o.table] the outbox table (default 'usage_outbox'); services create it in a migration
++ * @param {string} [o.billingUrl] OpenVibe.Billing's base URL; without it (or a tokenClient) only queueing happens
++ * @param {{ getToken(ctx?: { audience?: string }): Promise<string> }} [o.tokenClient] openvibe-sdk/auth's
++ * createServiceTokenClient (or anything with getToken)
++ * @param {string} [o.audience] the token audience (default 'openvibe.billing')
++ * @param {boolean} [o.requireProject] when true, record() refuses a reading whose `project` is not a
++ * `prj_…` id (default false: the reporter bills whatever reading it is given)
++ * @param {Function} [o.fetchImpl] fetch for the Billing POST (default globalThis.fetch)
++ * @param {number} [o.timeoutMs] the Billing POST timeout (default 5000)
++ * @param {number} [o.intervalMs] the relay tick (default 2000)
++ * @param {number} [o.batchSize] readings per publish (default 1: one reading per request)
++ * @param {() => number} [o.now] the clock the relay uses for backoff (tests pass one)
++ * @param {object} [o.log] warns once per distinct delivery error
++ * @returns {object} `{ enabled, service, source, table, key, sample, record, ensureSchema, start, stop, kick, flush, prune, pending, rejected }`
++ */
++function createUsageReporter({
++ db, service, source, table = 'usage_outbox', billingUrl, tokenClient, audience = 'openvibe.billing',
++ requireProject = false, fetchImpl = globalThis.fetch, timeoutMs = 5000, intervalMs = 2000, batchSize = 1,
++ now = () => Date.now(), log = console,
++} = {}) {
++ if (!db || typeof db.query !== 'function' || typeof db.tx !== 'function') throw new TypeError('createUsageReporter: an openvibe-sdk/db handle is required');
++ if (typeof service !== 'string' || !service) throw new TypeError('createUsageReporter: service must be a non-empty string');
++ if (typeof source !== 'string' || !source) throw new TypeError('createUsageReporter: source must be a non-empty string');
++ if (!TABLE_RE.test(table)) throw new TypeError('createUsageReporter: bad outbox table name');
++ if (fetchImpl != null && typeof fetchImpl !== 'function') throw new TypeError('createUsageReporter: fetchImpl must be a function');
++ const canRelay = Boolean(billingUrl && tokenClient && typeof tokenClient.getToken === 'function');
++
++ /** The service's stable idempotency key, e.g. key('job', id, n) -> `run:job:<id>:<n>`. */
++ function key(...parts) { return usageKey(service, ...parts); }
++
++ /** A reading for this service and source; `service` and `source` are the reporter's, `id` defaults to the key. */
++ function sample(fields) {
++ const f = fields || {};
++ return usageSample({ ...f, service, source, id: f.id == null ? f.idempotency_key : f.id });
++ }
++
++ /**
++ * INSIDE the caller's transaction: validate and queue the reading under its idempotency_key.
++ * -> true when this call inserted it, false when the key was already queued. An invalid reading
++ * throws (when openvibe-contracts is installed); a missing contracts never blocks the queue.
++ *
++ * The reporter bills whatever reading it is given: it cannot tell production from first-party or
++ * sandbox traffic, so never record those. With `requireProject: true`, a reading whose `project` is
++ * not a production project id (`prj_…`) is refused here instead of being queued.
++ */
++ async function record(t, reading) {
++ if (!t || typeof t.query !== 'function' || typeof t.tx !== 'function') throw new TypeError('usageReporter.record(t, reading): pass the transaction handle db.tx gives you');
++ if (!reading || typeof reading !== 'object') throw new TypeError('usageReporter.record: a platform.usage-sample@1 reading is required');
++ const v = validateUsageSample(reading);
++ if (!v.ok && v.errors.length) throw new Error(`usage reading ${reading.idempotency_key || reading.id || '(no key)'} is not a valid platform.usage-sample@1: ${JSON.stringify(v.errors)}`);
++ if (typeof reading.idempotency_key !== 'string' || !reading.idempotency_key) throw new TypeError('usageReporter.record: reading.idempotency_key is required (it is the outbox key)');
++ if (requireProject && !PROJECT_RE.test(String(reading.project || ''))) {
++ throw new Error(`usage reading ${reading.idempotency_key} has no production project (requireProject): first-party and sandbox traffic must not be billed`);
++ }
++ const n = await t.exec(`INSERT INTO ${table} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING`,
++ [reading.idempotency_key, JSON.stringify(reading), now()]);
++ return n > 0;
++ }
++
++ /** Create the outbox table where the handle may (tests, PGlite); services put outboxSchema(table) in a migration. */
++ const ensureSchema = () => db.query(outboxSchema(table));
++
++ // createPgOutbox's `events` seam pointed at Billing: one reading per POST. isPermanent() reads err.status.
++ const billingSink = {
++ prepare: (reading) => reading,
++ async publish(reading) {
++ const token = await tokenClient.getToken({ audience });
++ const res = await fetchImpl(`${String(billingUrl).replace(/\/+$/, '')}/api/v1/usage`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify(reading), signal: AbortSignal.timeout(timeoutMs),
++ });
++ // 201 written, 200 an identical reading replayed: either way Billing holds it.
++ if (!res.ok) {
++ // 401 means the cached token was refused: drop it so the retry mints a fresh one (as Events does).
++ if (res.status === 401) tokenClient.invalidate?.({ audience });
++ const err = new Error(`billing.usage.record answered ${res.status}`);
++ // createPgOutbox's isPermanent() reads err.status: 422 is permanent, 503 is retried. Only
++ // Billing refusing the reading itself (a 4xx that is not RETRYABLE_4XX) is permanent; a
++ // missing grant/route, a timeout, backpressure or a 5xx stays pending with backoff.
++ err.status = res.status >= 400 && res.status < 500 && !RETRYABLE_4XX.has(res.status) ? 422 : 503;
++ throw err;
++ }
++ return { event_id: reading.idempotency_key || reading.id, seq: null };
++ },
++ };
++ let lastError = null;
++ const relay = canRelay ? createPgOutbox(db, {
++ events: billingSink, table, batchSize, intervalMs, now,
++ onError: (err) => { const m = err && err.message; if (m !== lastError) { lastError = m; if (log && log.warn) log.warn(`[usage] ${service} reading not delivered (will retry): ${m}`); } },
++ }) : null;
++
++ const count = (where) => db.value(`SELECT count(*) FROM ${table} WHERE ${where}`).then(Number);
++
++ return {
++ enabled: canRelay, service, source, table,
++ key, sample, record, ensureSchema,
++ start: () => { if (relay) relay.start(); },
++ stop: () => (relay ? relay.stop() : Promise.resolve()),
++ kick: () => { if (relay) relay.kick(); },
++ flush: () => (relay ? relay.flush() : Promise.resolve({ sent: 0, failed: 0, rejected: 0 })),
++ prune: (olderThanMs) => (relay ? relay.prune(olderThanMs) : Promise.resolve(0)),
++ pending: () => count(STATUSES),
++ rejected: () => count('rejected_at IS NOT NULL'),
++ };
++}
++
++module.exports = { usageSample, usageKey, validateUsageSample, createUsageReporter };
ddiff --git a/test/usage-reporter.test.js b/test/usage-reporter.test.js
nnew file mode 100644
iindex 0000000..a9e2778
---- /dev/null
++++ b/test/usage-reporter.test.js
@@@ -0,0 +1,247 @@
++'use strict';
++/**
++ * openvibe-sdk/usage createUsageReporter: the shared step-7 relay. Key derivation, the idempotency key as the
++ * outbox key (a replay changes nothing), transient failures retried with backoff (never dropped), and only
++ * Billing refusing the reading itself (any 4xx but 401/403/404/408/425/429) rejects the row, kept with its
++ * error. PGlite is the database (createPgOutbox is what the reporter relays with); the Billing POST is a stub fetch.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { createDb, sql } = require('../src/db');
++const { usageSample, usageKey, validateUsageSample, createUsageReporter } = require('../src/usage');
++
++const quiet = { warn() {}, log() {}, error() {} };
++
++/** A Billing stub: records each POST and answers the queued statuses, 201 once they run out. An Error entry
++ * is thrown instead (a network failure). */
++function billingStub(statuses = []) {
++ const calls = [];
++ const fetchImpl = async (url, opts) => {
++ const status = statuses.length ? statuses.shift() : 201;
++ calls.push({ url, method: opts.method, auth: opts.headers.Authorization, reading: JSON.parse(opts.body) });
++ if (status instanceof Error) throw status;
++ return { ok: status >= 200 && status < 300, status };
++ };
++ return { calls, fetchImpl };
++}
++
++function tokensStub() {
++ const audiences = [];
++ const invalidations = [];
++ return { audiences, invalidations, tokenClient: {
++ getToken: async ({ audience } = {}) => { audiences.push(audience); return 'svc-token'; },
++ invalidate: ({ audience } = {}) => { invalidations.push(audience); },
++ } };
++}
++
++const reading = (n) => usageSample({ id: `run:job_A:${n}`, idempotency_key: `run:job_A:${n}`, service: 'run',
++ operation: 'function.invoke', quantity: 1, unit: 's', at: '2026-10-02T10:00:00Z', source: 'openvibe-node.worker' });
++
++async function open({ statuses = [], table } = {}) {
++ const db = createDb({ pglite: true });
++ const billing = billingStub(statuses);
++ const tokens = tokensStub();
++ const now = { t: 1_000_000 };
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', table,
++ billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
++ now: () => now.t, log: quiet });
++ await reporter.ensureSchema();
++ return { db, reporter, billing, tokens, now, done: () => db.close() };
++}
++
++/** One reading, one relay pass, against a Billing stub that answers `status` (an Error throws): the row's fate. */
++async function deliverOnce(status) {
++ const db = createDb({ pglite: true });
++ const billing = billingStub([status]);
++ const tokens = tokensStub();
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker',
++ billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
++ now: () => 1_000_000, log: quiet });
++ await reporter.ensureSchema();
++ try {
++ await db.tx((t) => reporter.record(t, reading(0)));
++ const stats = await reporter.flush();
++ return { stats, pending: await reporter.pending(), rejected: await reporter.rejected(),
++ lastError: await db.value(sql`SELECT last_error FROM ${sql.raw('usage_outbox')} WHERE event_id = ${'run:job_A:0'}`),
++ billing, tokens };
++ } finally { await db.close(); }
++}
++
++run([
++ ['createUsageReporter requires a db, a service and a source', async () => {
++ assert.throws(() => createUsageReporter({}), /db handle/);
++ assert.throws(() => createUsageReporter({ db: { query() {}, tx() {} }, source: 's' }), /service/);
++ assert.throws(() => createUsageReporter({ db: { query() {}, tx() {} }, service: 'run' }), /source/);
++ assert.throws(() => createUsageReporter({ db: { query() {}, tx() {} }, service: 'run', source: 's', table: 'bad-name' }), /table name/);
++ }],
++
++ ['key derivation: key() is usageKey(service, …) and sample() fills in service, source and id', async () => {
++ const { reporter, done } = await open();
++ try {
++ assert.equal(reporter.key('job', 'job_A', 3), 'run:job:job_A:3');
++ assert.equal(reporter.key(), 'run');
++ const s = reporter.sample({ idempotency_key: reporter.key('job', 'job_A', 0), operation: 'function.invoke',
++ quantity: 1, unit: 's', resource: 'job_A', at: '2026-10-02T10:00:00Z' });
++ assert.equal(s.service, 'run');
++ assert.equal(s.source, 'openvibe-node.worker');
++ assert.equal(s.id, s.idempotency_key, 'id defaults to the key');
++ assert.deepEqual(validateUsageSample(s), { ok: true, errors: [] });
++ const explicit = reporter.sample({ id: 'use-1', idempotency_key: 'k', service: 'someone-else', source: 'someone-else',
++ operation: 'function.invoke', quantity: 1, unit: 's' });
++ assert.equal(explicit.service, 'run', 'the reporter service wins');
++ assert.equal(explicit.source, 'openvibe-node.worker');
++ assert.equal(explicit.id, 'use-1', 'an explicit id is kept');
++ } finally { await done(); }
++ }],
++
++ ['record queues an idempotency-keyed row inside the caller’s transaction, and refuses a replay', async () => {
++ const { reporter, done, db } = await open();
++ try {
++ await assert.rejects(reporter.record(null, reading(0)), /transaction handle/);
++ await assert.rejects(reporter.record({}, reading(0)), /transaction handle/);
++ assert.equal(await db.tx((t) => reporter.record(t, reading(0))), true);
++ assert.equal(await db.tx((t) => reporter.record(t, reading(0))), false, 'the same key is refused');
++ // A different reading reusing the key is refused too: the outbox keeps the first.
++ assert.equal(await db.tx((t) => reporter.record(t, { ...reading(0), quantity: 99 })), false);
++ assert.equal(await reporter.pending(), 1);
++ const env = await db.value(sql`SELECT envelope FROM ${sql.raw('usage_outbox')} WHERE event_id = ${'run:job_A:0'}`);
++ assert.equal(env.quantity, 1, 'the first reading is the one kept');
++ assert.equal(env.idempotency_key, 'run:job_A:0');
++ assert.equal(await db.value(sql`SELECT count(*) FROM ${sql.raw('usage_outbox')}`), 1);
++ } finally { await done(); }
++ }],
++
++ ['record throws on a reading that does not match platform.usage-sample@1', async () => {
++ const { reporter, done, db } = await open();
++ try {
++ await assert.rejects(db.tx((t) => reporter.record(t, { id: 'x', idempotency_key: 'x' })), /platform\.usage-sample@1/);
++ await assert.rejects(db.tx((t) => reporter.record(t, { service: 'run', operation: 'o', quantity: 1, unit: 's' })), /idempotency_key/);
++ } finally { await done(); }
++ }],
++
++ ['a transient Billing failure retries with backoff and is never dropped or rejected', async () => {
++ const { reporter, billing, tokens, now, done, db } = await open({ statuses: [503, 503] });
++ try {
++ await db.tx((t) => reporter.record(t, reading(0)));
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 1, rejected: 0 });
++ assert.equal(await reporter.pending(), 1);
++ assert.equal(await reporter.rejected(), 0);
++ assert.equal(await db.value(sql`SELECT last_error FROM ${sql.raw('usage_outbox')} WHERE event_id = ${'run:job_A:0'}`), 'billing.usage.record answered 503');
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 0, rejected: 0 }, 'not due yet');
++ now.t += 1000;
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 1, rejected: 0 });
++ now.t += 5000;
++ assert.deepEqual(await reporter.flush(), { sent: 1, failed: 0, rejected: 0 });
++ assert.equal(await reporter.pending(), 0);
++ assert.equal(billing.calls.length, 3);
++ assert.equal(billing.calls[0].url, 'http://billing.internal/api/v1/usage');
++ assert.equal(billing.calls[0].method, 'POST');
++ assert.equal(billing.calls[0].auth, 'Bearer svc-token');
++ assert.deepEqual(tokens.audiences, ['openvibe.billing', 'openvibe.billing', 'openvibe.billing']);
++ assert.equal(billing.calls[0].reading.idempotency_key, 'run:job_A:0');
++ } finally { await done(); }
++ }],
++
++ ['only Billing refusing the reading itself rejects the row, which is kept with its error', async () => {
++ const { reporter, done, db } = await open({ statuses: [422] });
++ try {
++ await db.tx((t) => reporter.record(t, reading(0)));
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 0, rejected: 1 });
++ assert.equal(await reporter.pending(), 0);
++ assert.equal(await reporter.rejected(), 1);
++ assert.equal(await db.value(sql`SELECT last_error FROM ${sql.raw('usage_outbox')} WHERE event_id = ${'run:job_A:0'}`), 'billing.usage.record answered 422');
++ assert.ok(await db.value(sql`SELECT rejected_at FROM ${sql.raw('usage_outbox')} WHERE event_id = ${'run:job_A:0'}`), 'the refused row is kept, never deleted');
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 0, rejected: 0 }, 'a rejected row is never sent again');
++ } finally { await done(); }
++ }],
++
++ ['without a billingUrl or tokenClient the reporter still queues, but the relay is off', async () => {
++ const db = createDb({ pglite: true });
++ try {
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', log: quiet });
++ assert.equal(reporter.enabled, false);
++ await reporter.ensureSchema();
++ await assert.rejects(reporter.record(null, reading(0)), /transaction handle/);
++ await db.tx((t) => reporter.record(t, reading(0)));
++ assert.equal(await reporter.pending(), 1, 'readings wait for a process with Billing configured');
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 0, rejected: 0 });
++ assert.equal(typeof usageKey('run', 'job_A', 0), 'string');
++ } finally { await db.close(); }
++ }],
++
++ ['the full Billing status matrix: only the reading itself is refused', async () => {
++ // Credentials, grant, address or load: the reading is fine, so it stays pending and is retried.
++ for (const status of [401, 403, 404, 408, 425, 429, 500]) {
++ const r = await deliverOnce(status);
++ assert.deepEqual(r.stats, { sent: 0, failed: 1, rejected: 0 }, `${status} stays pending`);
++ assert.equal(r.pending, 1, `${status} is never dropped`);
++ assert.equal(r.rejected, 0, `${status} is not the reading being refused`);
++ assert.equal(r.lastError, `billing.usage.record answered ${status}`, `${status} keeps the real status in its error`);
++ }
++ // Billing refusing the reading itself: rejected, kept with its error, never sent again.
++ for (const status of [400, 402, 409, 410, 413, 415, 422]) {
++ const r = await deliverOnce(status);
++ assert.deepEqual(r.stats, { sent: 0, failed: 0, rejected: 1 }, `${status} is refused`);
++ assert.equal(r.pending, 0, `${status} leaves the queue`);
++ assert.equal(r.rejected, 1);
++ assert.equal(r.lastError, `billing.usage.record answered ${status}`);
++ }
++ }],
++
++ ['a network error from Billing (fetch throws) stays pending with backoff', async () => {
++ const r = await deliverOnce(new Error('connect ECONNREFUSED 127.0.0.1:443'));
++ assert.deepEqual(r.stats, { sent: 0, failed: 1, rejected: 0 });
++ assert.equal(r.pending, 1, 'a network error is never the reading being refused');
++ assert.equal(r.rejected, 0);
++ }],
++
++ ['a 401 from Billing invalidates the cached service token before the retry', async () => {
++ const { reporter, billing, tokens, now, db, done } = await open({ statuses: [401, 201] });
++ try {
++ await db.tx((t) => reporter.record(t, reading(0)));
++ assert.deepEqual(await reporter.flush(), { sent: 0, failed: 1, rejected: 0 });
++ assert.equal(await reporter.pending(), 1, 'a 401 is not the reading being refused');
++ assert.deepEqual(tokens.invalidations, ['openvibe.billing'], 'the refused token is dropped');
++ now.t += 1000;
++ assert.deepEqual(await reporter.flush(), { sent: 1, failed: 0, rejected: 0 });
++ assert.deepEqual(tokens.audiences, ['openvibe.billing', 'openvibe.billing'], 'the retry mints a fresh token');
++ assert.deepEqual(tokens.invalidations, ['openvibe.billing'], 'only the 401 dropped the token');
++ assert.equal(billing.calls.length, 2);
++ } finally { await done(); }
++ }],
++
++ ['requireProject refuses a reading without a production project, and accepts one with it', async () => {
++ const db = createDb({ pglite: true });
++ try {
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', requireProject: true, log: quiet });
++ await reporter.ensureSchema();
++ await assert.rejects(db.tx((t) => reporter.record(t, reading(0))), /production project/, 'a first-party reading (no project) is refused');
++ // A malformed project fails contracts validation first; either way the reading is refused.
++ await assert.rejects(db.tx((t) => reporter.record(t, { ...reading(0), project: 'sandbox' })), /production project|platform\.usage-sample@1/, 'a non-project project is refused');
++ assert.equal(await db.tx((t) => reporter.record(t, { ...reading(0), project: 'prj_00000000000000000000000000' })), true);
++ assert.equal(await reporter.pending(), 1);
++ } finally { await db.close(); }
++ }],
++
++ ['start() schedules an unref’d relay timer and stop() clears it', async () => {
++ const { reporter, done } = await open();
++ const realSetTimeout = globalThis.setTimeout;
++ const realClearTimeout = globalThis.clearTimeout;
++ const scheduled = [];
++ const cleared = [];
++ globalThis.setTimeout = (fn, ms) => { const t = realSetTimeout(fn, ms); scheduled.push(t); return t; };
++ globalThis.clearTimeout = (t) => { cleared.push(t); return realClearTimeout(t); };
++ try {
++ reporter.start();
++ assert.equal(scheduled.length, 1, 'start schedules one timer');
++ assert.equal(typeof scheduled[0].unref, 'function');
++ assert.equal(scheduled[0].hasRef(), false, 'the relay timer is unref’d (it never keeps the process alive)');
++ await reporter.stop();
++ assert.ok(cleared.includes(scheduled[0]), 'stop clears the relay timer');
++ } finally {
++ globalThis.setTimeout = realSetTimeout;
++ globalThis.clearTimeout = realClearTimeout;
++ await done();
++ }
++ }],
++]);
ddiff --git a/types/usage.d.ts b/types/usage.d.ts
iindex 2965f4e..180a1bd 100644
---- a/types/usage.d.ts
++++ b/types/usage.d.ts
@@@ -11,3 +11,70 @@ export declare function usageSample(fields: Partial<UsageSample>): UsageSample;
export declare function usageKey(service: string, ...parts: (string | number)[]): string;
/** { ok, errors } against platform.usage-sample@1; a missing openvibe-contracts never claims validity (ok: false, errors: []). */
export declare function validateUsageSample(record: unknown): { ok: boolean; errors: ValidationError[] };
++
++/** Anything with getToken({ audience }) — openvibe-sdk/auth's createServiceTokenClient. `invalidate` drops the
++ * cached token (called for a Billing 401 so the retry mints a fresh one). */
++export type UsageTokenClient = { getToken(ctx?: { audience?: string; scope?: string | string[] }): Promise<string>;
++ invalidate?(ctx?: { audience?: string }): void };
++
++export type UsageReporterOptions = {
++ /** An openvibe-sdk/db handle (server); the outbox table lives in the same database. */
++ db: unknown;
++ /** The Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'. */
++ service: string;
++ /** The sample's `source`, e.g. 'openvibe-node.worker', 'ai.runs'. */
++ source: string;
++ /** The outbox table (default 'usage_outbox'); services create it in a migration. */
++ table?: string;
++ /** OpenVibe.Billing's base URL; without it (or a tokenClient) only queueing happens. */
++ billingUrl?: string;
++ tokenClient?: UsageTokenClient | null;
++ /** The token audience (default 'openvibe.billing'). */
++ audience?: string;
++ /** When true, `record()` refuses a reading whose `project` is not a `prj_…` id (default false: the
++ * reporter bills whatever reading it is given, so callers must never record first-party or sandbox traffic). */
++ requireProject?: boolean;
++ fetchImpl?: import('./core').FetchLike;
++ /** The Billing POST timeout (default 5000). */
++ timeoutMs?: number;
++ /** The relay tick (default 2000). */
++ intervalMs?: number;
++ /** Readings per publish (default 1: one reading per request). */
++ batchSize?: number;
++ now?: () => number;
++ log?: { warn?(...args: unknown[]): void };
++};
++
++/** One service's usage reporter: build/validate readings, queue them idempotency-keyed, relay to Billing. */
++export interface UsageReporter {
++ readonly enabled: boolean;
++ readonly service: string;
++ readonly source: string;
++ readonly table: string;
++ /** The service's stable key, e.g. key('job', id, n) -> 'run:job:<id>:<n>'. */
++ key(...parts: (string | number)[]): string;
++ /** A reading for this service and source; `id` defaults to `idempotency_key`. */
++ sample(fields: Partial<UsageSample>): UsageSample;
++ /** INSIDE the caller's transaction (db.tx's handle): queue the reading under its idempotency_key. The
++ * reporter bills whatever reading it is given — never record first-party or sandbox traffic; with
++ * `requireProject: true` a reading without a `prj_…` project is refused. */
++ record(t: unknown, reading: UsageSample | Record<string, unknown>): Promise<boolean>;
++ /** Create the outbox table where the handle may (tests, PGlite); services put it in a migration instead. */
++ ensureSchema(): Promise<unknown>;
++ start(): void;
++ stop(): Promise<unknown>;
++ kick(): void;
++ flush(): Promise<{ sent: number; failed: number; rejected: number }>;
++ prune(olderThanMs?: number): Promise<unknown>;
++ pending(): Promise<number>;
++ rejected(): Promise<number>;
++}
++
++/**
++ * The shared step-7 reporter: readings queue idempotency-keyed in `table` inside the caller's transaction and
++ * relay to Billing's billing.usage.record with createPgOutbox. Never dropped (transient failures — 5xx,
++ * network errors and 4xx about credentials, grant, address or load: 401/403/404/408/425/429 — retry with
++ * backoff across restarts; a 401 also drops the cached token); only Billing refusing the reading itself (any
++ * other 4xx: 400, 402, 409, 410, 413, 415, 422 …) marks a row rejected, kept with its error and never sent again.
++ */
++export declare function createUsageReporter(options: UsageReporterOptions): UsageReporter;