From the survey of every env-file secret (ADR 0086, issue 041): amqp-ping, minio, mongodb and grafana use the _FILE twin their software honours; mesh-catalog and model-usage read DATABASE_URL_FILE (a file the mesh templates, mounted where only the runtime reads it); grafana's secret files belong to its own account. Two dead deliveries removed: a line nothing read in amqp-email-forwarder, and mailu's secret.env on four containers that never read it. The 25 exceptions that remain carry the surveyed reason — convertible and awaiting a bed, convertible through a generated config file, the application's own code, or not convertible.
101 lines
4.1 KiB
TypeScript
101 lines
4.1 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
// The vendor-neutral usage store (novox/hq ADR 0054). ONE table holds BOTH grains — licence and
|
|
// session — which differ only in `consumer`; a reading is one row `(licence, consumer, period,
|
|
// metric, value)` plus its `raw` vendor payload. The store keeps the LATEST reading per
|
|
// `(licence, consumer, period, metric)`: an event carries a fresh total, and the upsert replaces the
|
|
// previous one. Delivery is at-least-once, so an upsert is idempotent-latest by design — a duplicate
|
|
// event writes the same row again, never a second.
|
|
//
|
|
// Usage is stored IN THE CLEAR (ADR 0054): the value and its raw payload are ordinary columns, not
|
|
// sealed. This is not a credential; it is a reading a query answers.
|
|
|
|
import pg from "pg";
|
|
import { requireEnv } from "@novox/mesh-sdk/primitives";
|
|
|
|
// `pg` is CommonJS: its default export is the module object, so the Pool class is a property of it.
|
|
// Destructuring the default is the form that resolves at runtime under Node's ESM loader (the same
|
|
// shape anthropic-manager uses for tweetnacl-sealedbox-js).
|
|
const { Pool } = pg;
|
|
type PgPool = InstanceType<typeof Pool>;
|
|
|
|
/** One usage reading — the vendor-neutral row ADR 0054 fixes. `raw` carries the vendor payload the
|
|
* reading was normalised from (model, windows, timestamps), so nothing is lost by normalising. */
|
|
export interface UsageRow {
|
|
licence: string;
|
|
consumer: string;
|
|
period: string;
|
|
metric: string;
|
|
value: number;
|
|
raw?: unknown;
|
|
}
|
|
|
|
const DDL = `
|
|
CREATE TABLE IF NOT EXISTS usage (
|
|
licence text NOT NULL,
|
|
consumer text NOT NULL,
|
|
period text NOT NULL,
|
|
metric text NOT NULL,
|
|
value double precision NOT NULL,
|
|
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (licence, consumer, period, metric)
|
|
);
|
|
`;
|
|
|
|
export class UsageStore {
|
|
private constructor(private readonly pool: PgPool) {}
|
|
|
|
/** Build a store from the resolved environment — DATABASE_URL is the granted postgres connection,
|
|
* templated into the module's env-file from the mesh's binding (umami's DATABASE_URL precedent). */
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): UsageStore {
|
|
// As a file first (novox/hq ADR 0086): the connection string carries the password.
|
|
const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]);
|
|
if (!url) throw new Error("DATABASE_URL_FILE (or DATABASE_URL) is not set — model-usage cannot reach its database");
|
|
return new UsageStore(new Pool({ connectionString: url }));
|
|
}
|
|
|
|
/** Create the one table if it is not there. Run once by the migrate entry before the consumer
|
|
* starts; idempotent, so re-running is harmless. */
|
|
async migrate(): Promise<void> {
|
|
await this.pool.query(DDL);
|
|
}
|
|
|
|
/** Upsert a reading, keeping the latest per `(licence, consumer, period, metric)`. */
|
|
async upsert(row: UsageRow): Promise<void> {
|
|
await this.pool.query(
|
|
`INSERT INTO usage (licence, consumer, period, metric, value, raw)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
|
ON CONFLICT (licence, consumer, period, metric)
|
|
DO UPDATE SET value = excluded.value, raw = excluded.raw, updated_at = now()`,
|
|
[row.licence, row.consumer, row.period, row.metric, row.value, JSON.stringify(row.raw ?? {})],
|
|
);
|
|
}
|
|
|
|
/** The current reading per key, whole or filtered to one licence — for the read-only tool. */
|
|
async current(licence?: string): Promise<UsageRow[]> {
|
|
const where = licence ? "WHERE licence = $1" : "";
|
|
const params = licence ? [licence] : [];
|
|
const { rows } = await this.pool.query(
|
|
`SELECT licence, consumer, period, metric, value, raw
|
|
FROM usage ${where}
|
|
ORDER BY licence, consumer, period, metric`,
|
|
params,
|
|
);
|
|
return rows as UsageRow[];
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.pool.end();
|
|
}
|
|
}
|
|
|
|
/** The content of a file the environment names, its line ending gone — or undefined when it names none. */
|
|
function readMaybe(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try {
|
|
return readFileSync(path, "utf8").replace(/\r?\n$/, "");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|