@ultimat3/action 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The process default meter store: one bounded Map, each draw a synchronous check-and-add with no
3
+ * `await` between the two, so parallel calls in one process cannot overshoot. `scope: 'process'`:
4
+ * N replicas each holding one enforce N × every limit — the boot installs the Postgres store.
5
+ */
6
+
7
+ import { finiteCount } from '@ultimat3/core';
8
+ import type { MeterPeriod } from './meter';
9
+ import { METER_PERIODS } from './meter';
10
+ import { meterWindow } from './meter-period';
11
+ import type { MeterDraw, MeterDrawResult, MeterKey, MeterStore } from './meter-store';
12
+
13
+ /** One entry per tenant × meter × period declaration — the bound, not a cache size. */
14
+ export const DEFAULT_MAX_METER_KEYS = 20_000;
15
+
16
+ export interface MemoryMeterStoreOptions {
17
+ readonly maxKeys?: number;
18
+ }
19
+
20
+ interface Counter {
21
+ periodStartMs: number;
22
+ /** When `periodStartMs`'s period ends; `Infinity` for a period key this store cannot read. */
23
+ periodEndMs: number;
24
+ used: number;
25
+ warned: boolean;
26
+ }
27
+
28
+ const PERIODS: ReadonlySet<string> = new Set<string>(METER_PERIODS);
29
+
30
+ /** `day@UTC` → the end of the period starting at `startMs`; the zone is UTC, the only one. */
31
+ function periodEnd(period: string, startMs: number): number {
32
+ const name = period.split('@')[0] ?? '';
33
+ return PERIODS.has(name)
34
+ ? meterWindow(name as MeterPeriod, startMs).endMs
35
+ : Number.POSITIVE_INFINITY;
36
+ }
37
+
38
+ /** A JSON tuple, never a joined string: no subject or meter can forge another's key. */
39
+ const keyOf = (key: MeterKey): string => JSON.stringify([key.subject, key.meter, key.period]);
40
+
41
+ export function memoryMeterStore(options: MemoryMeterStoreOptions = {}): MeterStore {
42
+ const maxKeys = finiteCount(
43
+ 'memoryMeterStore',
44
+ 'maxKeys',
45
+ options.maxKeys ?? DEFAULT_MAX_METER_KEYS,
46
+ 1,
47
+ );
48
+ const counters = new Map<string, Counter>();
49
+
50
+ /** The latest period start any draw named: a moment known to have passed, with no clock read. */
51
+ let passedMs = Number.NEGATIVE_INFINITY;
52
+
53
+ /**
54
+ * Past the bound a counter whose period already ENDED goes first — it is worth nothing — then the
55
+ * oldest period start, then insertion order. By start alone a live month counter went before an
56
+ * hour counter that had finished. Forgetting a live counter forgives that tenant's usage, which
57
+ * is a weaker limit in one process, never a refusal of somebody who is within theirs.
58
+ */
59
+ const evict = (): void => {
60
+ if (counters.size < maxKeys) return;
61
+ let victim: string | undefined;
62
+ let victimFinished = false;
63
+ let victimStart = Number.POSITIVE_INFINITY;
64
+ for (const [id, counter] of counters) {
65
+ const finished = counter.periodEndMs <= passedMs;
66
+ if (finished && !victimFinished) {
67
+ victim = id;
68
+ victimFinished = true;
69
+ victimStart = counter.periodStartMs;
70
+ } else if (finished === victimFinished && counter.periodStartMs < victimStart) {
71
+ victim = id;
72
+ victimStart = counter.periodStartMs;
73
+ }
74
+ }
75
+ if (victim !== undefined) counters.delete(victim);
76
+ };
77
+
78
+ return {
79
+ scope: 'process',
80
+
81
+ async draw(request: MeterDraw): Promise<MeterDrawResult> {
82
+ if (Number.isFinite(request.periodStartMs))
83
+ passedMs = Math.max(passedMs, request.periodStartMs);
84
+ const id = keyOf(request);
85
+ const existing = counters.get(id);
86
+ // A later period resets the count; an EARLIER one (a replica whose clock runs behind) adds to
87
+ // the current one — the Postgres statement's `greatest`, so both stores answer alike.
88
+ const current =
89
+ existing === undefined || request.periodStartMs > existing.periodStartMs
90
+ ? undefined
91
+ : existing;
92
+ const used = (current?.used ?? 0) + 1;
93
+ if (used > request.limit) {
94
+ return { allowed: false, used: current?.used ?? 0, warned: current?.warned ?? false };
95
+ }
96
+ if (current === undefined) {
97
+ if (existing === undefined) evict();
98
+ counters.set(id, {
99
+ periodStartMs: request.periodStartMs,
100
+ periodEndMs: periodEnd(request.period, request.periodStartMs),
101
+ used,
102
+ warned: false,
103
+ });
104
+ return { allowed: true, used, warned: false };
105
+ }
106
+ current.used = used;
107
+ return { allowed: true, used, warned: current.warned };
108
+ },
109
+
110
+ async claimWarning(key, periodStartMs): Promise<boolean> {
111
+ const counter = counters.get(keyOf(key));
112
+ if (counter === undefined || counter.periodStartMs !== periodStartMs || counter.warned) {
113
+ return false;
114
+ }
115
+ counter.warned = true;
116
+ return true;
117
+ },
118
+
119
+ async releaseWarning(key, periodStartMs): Promise<void> {
120
+ const counter = counters.get(keyOf(key));
121
+ if (counter !== undefined && counter.periodStartMs === periodStartMs) counter.warned = false;
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which period an instant falls in, for a meter: its start and its end, in UTC. UTC and never an
3
+ * ambient zone — every replica must compute the same boundary for the same instant, or two pods
4
+ * count one tenant's day against two different rows. The stored key names the zone (`day@UTC`),
5
+ * so a zone-aware period added later is a new counter, never a reinterpreted one.
6
+ */
7
+
8
+ import type { MeterPeriod } from './meter';
9
+
10
+ export interface MeterWindow {
11
+ readonly startMs: number;
12
+ readonly endMs: number;
13
+ }
14
+
15
+ const HOUR_MS = 3_600_000;
16
+
17
+ export function meterWindow(period: MeterPeriod, nowMs: number): MeterWindow {
18
+ if (period === 'hour') {
19
+ const startMs = Math.floor(nowMs / HOUR_MS) * HOUR_MS;
20
+ return { startMs, endMs: startMs + HOUR_MS };
21
+ }
22
+ const at = new Date(nowMs);
23
+ const year = at.getUTCFullYear();
24
+ const month = at.getUTCMonth();
25
+ if (period === 'day') {
26
+ const day = at.getUTCDate();
27
+ return { startMs: Date.UTC(year, month, day), endMs: Date.UTC(year, month, day + 1) };
28
+ }
29
+ // `Date.UTC` normalises month 12 to January of the next year, so December needs no branch.
30
+ return { startMs: Date.UTC(year, month, 1), endMs: Date.UTC(year, month + 1, 1) };
31
+ }
32
+
33
+ /** The period half of the stored key — the zone spelled out so it can never be assumed. */
34
+ export const meterPeriodKey = (period: MeterPeriod): string => `${period}@UTC`;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The SHARED meter store: one Postgres row per tenant × meter × period declaration, one
3
+ * conditional `insert … on conflict` per draw, so every replica counts against one number and a
4
+ * restart forgets nothing. Statements are spelled out so an agent can run the exact one it saw.
5
+ */
6
+
7
+ import type { PgExecutor } from '@ultimat3/core';
8
+ import type { MeterDraw, MeterDrawResult, MeterKey, MeterStore } from './meter-store';
9
+
10
+ /**
11
+ * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows.
12
+ *
13
+ * **One row per counter, rolled over in place.** The row holds the CURRENT period only: a draw in
14
+ * a later period resets it, so the table is bounded by tenants × meters and needs no purge — the
15
+ * unbounded-table cost `x_rate_limit` pays (#296) is not paid twice. Usage history is not kept:
16
+ * an app that bills on it records its own, which is a business table, not a counter.
17
+ */
18
+ export const SQL_METER_TABLE = `
19
+ create table if not exists x_meter (
20
+ subject text not null,
21
+ meter text not null,
22
+ period text not null,
23
+ period_start timestamptz not null,
24
+ used bigint not null,
25
+ warned boolean not null default false,
26
+ updated_at timestamptz not null default now(),
27
+ primary key (subject, meter, period)
28
+ );
29
+ `;
30
+
31
+ /**
32
+ * `$1` subject, `$2` meter, `$3` period key, `$4` the period start, `$5` the limit.
33
+ *
34
+ * Atomic and conditional in ONE statement: the `insert … select … where` admits a first draw only
35
+ * within the limit, and `do update … where` re-checks against the row AFTER its lock is taken — a
36
+ * direct `x_meter.used` reference, never a CTE, which would read the statement's snapshot and let
37
+ * two racing draws both see the last free slot. No row back means refused, and nothing changed.
38
+ *
39
+ * `period_start` only moves FORWARD (`greatest`): a replica whose clock runs behind adds to the
40
+ * current period instead of resetting it to the one before.
41
+ */
42
+ const SQL_METER_DRAW = `
43
+ insert into x_meter (subject, meter, period, period_start, used, warned)
44
+ select $1, $2, $3, $4::timestamptz, 1, false
45
+ where 1 <= $5::bigint
46
+ on conflict (subject, meter, period) do update set
47
+ used = case when excluded.period_start > x_meter.period_start then 1 else x_meter.used + 1 end,
48
+ warned = case when excluded.period_start > x_meter.period_start then false else x_meter.warned end,
49
+ period_start = greatest(x_meter.period_start, excluded.period_start),
50
+ updated_at = now()
51
+ where (case when excluded.period_start > x_meter.period_start then 0 else x_meter.used end) + 1
52
+ <= $5::bigint
53
+ returning used, warned
54
+ `;
55
+
56
+ /** What a refused draw is told it had used — read only on a refusal, never before a draw. */
57
+ const SQL_METER_READ = `
58
+ select used, warned from x_meter
59
+ where subject = $1 and meter = $2 and period = $3 and period_start >= $4::timestamptz
60
+ `;
61
+
62
+ /** Exactly one claimant per period: the `not warned` predicate under the row lock. */
63
+ const SQL_METER_CLAIM = `
64
+ update x_meter set warned = true
65
+ where subject = $1 and meter = $2 and period = $3 and period_start = $4::timestamptz and not warned
66
+ returning subject
67
+ `;
68
+
69
+ const SQL_METER_RELEASE = `
70
+ update x_meter set warned = false
71
+ where subject = $1 and meter = $2 and period = $3 and period_start = $4::timestamptz
72
+ `;
73
+
74
+ interface CounterRow {
75
+ /** `bigint`, which a client hands back as a string. */
76
+ readonly used: number | string;
77
+ /** `boolean`, which a text-mode client hands back as `'t'`. */
78
+ readonly warned: boolean | string;
79
+ }
80
+
81
+ export interface PostgresMeterStoreOptions {
82
+ readonly executor: PgExecutor;
83
+ }
84
+
85
+ const isTrue = (value: boolean | string): boolean => value === true || value === 't';
86
+ const iso = (ms: number): string => new Date(ms).toISOString();
87
+ const keyParams = (key: MeterKey, periodStartMs: number): readonly unknown[] => [
88
+ key.subject,
89
+ key.meter,
90
+ key.period,
91
+ iso(periodStartMs),
92
+ ];
93
+
94
+ /**
95
+ * Installed by the CLI boot over the pool it already opened — never the open transaction's
96
+ * connection: a draw is the caller's whether or not the business write commits, as a handler that
97
+ * threw may have committed and is charged for that reason.
98
+ */
99
+ export function postgresMeterStore(options: PostgresMeterStoreOptions): MeterStore {
100
+ const exec = options.executor;
101
+ return {
102
+ scope: 'shared',
103
+
104
+ async draw(request: MeterDraw): Promise<MeterDrawResult> {
105
+ const rows = await exec.query<CounterRow>(SQL_METER_DRAW, [
106
+ ...keyParams(request, request.periodStartMs),
107
+ request.limit,
108
+ ]);
109
+ const row = rows[0];
110
+ if (row !== undefined) {
111
+ return { allowed: true, used: Number(row.used), warned: isTrue(row.warned) };
112
+ }
113
+ const current = (
114
+ await exec.query<CounterRow>(SQL_METER_READ, keyParams(request, request.periodStartMs))
115
+ )[0];
116
+ return {
117
+ allowed: false,
118
+ used: current === undefined ? 0 : Number(current.used),
119
+ warned: current !== undefined && isTrue(current.warned),
120
+ };
121
+ },
122
+
123
+ async claimWarning(key, periodStartMs): Promise<boolean> {
124
+ const rows = await exec.query(SQL_METER_CLAIM, keyParams(key, periodStartMs));
125
+ return rows.length === 1;
126
+ },
127
+
128
+ async releaseWarning(key, periodStartMs): Promise<void> {
129
+ await exec.query(SQL_METER_RELEASE, keyParams(key, periodStartMs));
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The meter store SEAM: what a counter must answer, and the installed-store slot `invoke` draws
3
+ * from. The process default is `memoryMeterStore()`; the boot installs `postgresMeterStore()` on
4
+ * the pool it opened, so a tenant's day is one counter across every replica and every restart.
5
+ */
6
+
7
+ import { memoryMeterStore } from './meter-memory';
8
+
9
+ /** One counter: whose, which meter, which period declaration. */
10
+ export interface MeterKey {
11
+ /** `org:<id>`, else `actor:<id>`, else `job:unattributed` or `anonymous` — `meter-gate.ts`. */
12
+ readonly subject: string;
13
+ readonly meter: string;
14
+ /** `day@UTC` — the period AND its zone, so a re-declared period is a new counter. */
15
+ readonly period: string;
16
+ }
17
+
18
+ export interface MeterDraw extends MeterKey {
19
+ /** The current period's start, epoch ms. A later start than the stored one resets the count. */
20
+ readonly periodStartMs: number;
21
+ /** The allowance for THIS call, resolved by the caller: a plan change applies at once. */
22
+ readonly limit: number;
23
+ }
24
+
25
+ export interface MeterDrawResult {
26
+ /** `false`: the draw would pass `limit`, and nothing was counted. */
27
+ readonly allowed: boolean;
28
+ /** The count after this draw, or the count that refused it. */
29
+ readonly used: number;
30
+ /** Whether this period's warning was already claimed. */
31
+ readonly warned: boolean;
32
+ }
33
+
34
+ export interface MeterStore {
35
+ /** Declared, never inferred: a driver knows where its counters live. */
36
+ readonly scope: 'process' | 'shared';
37
+ /**
38
+ * Count one call, atomically, only while the count stays within `limit`: never a read followed
39
+ * by a write, so N parallel draws at the edge admit exactly the remainder.
40
+ */
41
+ draw(request: MeterDraw): Promise<MeterDrawResult>;
42
+ /** Claim this period's warning. `true` for exactly one caller per period. */
43
+ claimWarning(key: MeterKey, periodStartMs: number): Promise<boolean>;
44
+ /** Give a claim back — the enqueue it was for failed, so the next draw may try again. */
45
+ releaseWarning(key: MeterKey, periodStartMs: number): Promise<void>;
46
+ }
47
+
48
+ let installed: MeterStore = memoryMeterStore();
49
+
50
+ export function setMeterStore(store: MeterStore): void {
51
+ installed = store;
52
+ }
53
+
54
+ export function getMeterStore(): MeterStore {
55
+ return installed;
56
+ }
57
+
58
+ /** Back to a fresh process default. The boot's teardown, and a test's. */
59
+ export function resetMeterStore(): void {
60
+ installed = memoryMeterStore();
61
+ }
package/src/meter.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * An action's `meter:` declaration — a durable per-period allowance drawn inside `invoke` — and the
3
+ * checks that refuse one that could not be enforced. The counting is `meter-gate.ts`'s; the
4
+ * counters are the installed `MeterStore`'s. Plans, prices and what a tenant may buy are the app's.
5
+ */
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import { renderCauseValue } from '@ultimat3/core';
9
+ import { type Infer, t } from '@ultimat3/schema';
10
+ import { MeterInvalidError } from './errors-meter';
11
+
12
+ /** The periods a meter resets on. Boundaries are UTC (`meter-period.ts`), and the key says so. */
13
+ export const METER_PERIODS = Object.freeze(['hour', 'day', 'month'] as const);
14
+
15
+ export type MeterPeriod = (typeof METER_PERIODS)[number];
16
+
17
+ /** What a call-time limit is resolved from: the caller's context, never the request. */
18
+ export interface MeterLimitArgs {
19
+ readonly ctx: Ctx;
20
+ }
21
+
22
+ /** A number, or one resolved per call from `ctx` — the tenant's plan, read by the app. */
23
+ export type ResolvedLimit = number | ((args: MeterLimitArgs) => number | Promise<number>);
24
+
25
+ /**
26
+ * What a crossed `warnAt` hands over: one per tenant, meter and period, after the draw that
27
+ * crossed it landed. A schema, so the job an app declares to receive it parses the same shape.
28
+ */
29
+ export const MeterWarning = t.object({
30
+ action: t.string,
31
+ meter: t.string,
32
+ orgId: t.nullable(t.string),
33
+ actorId: t.nullable(t.string),
34
+ used: t.number.int(),
35
+ limit: t.number.int(),
36
+ periodStart: t.string,
37
+ resetsAt: t.string,
38
+ });
39
+
40
+ export type MeterWarning = Infer<typeof MeterWarning>;
41
+
42
+ /** How the warning is enqueued: outside the caller's transaction, attributed to its tenant. */
43
+ export interface MeterWarnEnqueueOptions {
44
+ readonly outbox: false;
45
+ readonly tenantId?: string;
46
+ }
47
+
48
+ /**
49
+ * Where a crossed threshold goes — a `job()` from `@ultimat3/jobs` satisfies it as written. A
50
+ * structural type, because `@ultimat3/jobs` is this package's tier and can never be imported here.
51
+ */
52
+ export interface MeterWarnTarget {
53
+ enqueue(warning: MeterWarning, options: MeterWarnEnqueueOptions): Promise<unknown>;
54
+ }
55
+
56
+ export interface ActionMeter {
57
+ /** The counter's name. Two actions naming one meter with one period draw on one counter. */
58
+ readonly name: string;
59
+ readonly period: MeterPeriod;
60
+ /** Calls allowed per period: a whole number, `0` refusing every call. */
61
+ readonly limit: ResolvedLimit;
62
+ /** The fraction of `limit` whose crossing enqueues `onWarn`, once per period. `(0, 1]`. */
63
+ readonly warnAt?: number;
64
+ /** Required with `warnAt`, and only with it. */
65
+ readonly onWarn?: MeterWarnTarget;
66
+ }
67
+
68
+ /** What `describe()` publishes: the declaration minus the function and the target. */
69
+ export interface ActionMeterFact {
70
+ readonly name: string;
71
+ readonly period: MeterPeriod;
72
+ readonly timeZone: 'UTC';
73
+ /** `null` when the limit is resolved per call — no static number exists to publish. */
74
+ readonly limit: number | null;
75
+ readonly warnAt: number | null;
76
+ }
77
+
78
+ /** A meter name is a key in a durable table and a word in a cause: kept to a plain identifier. */
79
+ const METER_NAME = /^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)*$/;
80
+ const METER_NAME_MAX = 64;
81
+
82
+ const isMeterPeriod = (value: unknown): value is MeterPeriod =>
83
+ typeof value === 'string' && (METER_PERIODS as readonly string[]).includes(value);
84
+
85
+ /** A whole, non-negative, exactly representable count of calls. */
86
+ export const isMeterLimit = (value: unknown): value is number =>
87
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
88
+
89
+ /** At `action()`: a meter that could not be counted is refused where it is written. */
90
+ export function assertMeterDeclaration(meter: ActionMeter): void {
91
+ const name: unknown = meter.name;
92
+ if (typeof name !== 'string' || name.length > METER_NAME_MAX || !METER_NAME.test(name)) {
93
+ const shown = typeof name === 'string' ? name : renderCauseValue(name);
94
+ throw new MeterInvalidError(shown, 'name', 'a camelCase identifier of at most 64 chars');
95
+ }
96
+ if (!isMeterPeriod(meter.period)) {
97
+ throw new MeterInvalidError(name, 'period', `one of ${METER_PERIODS.join(' | ')}`);
98
+ }
99
+ if (typeof meter.limit !== 'function' && !isMeterLimit(meter.limit)) {
100
+ throw new MeterInvalidError(name, 'limit', 'a whole number of calls, 0 or more');
101
+ }
102
+ const warnAt: unknown = meter.warnAt;
103
+ if (warnAt !== undefined) {
104
+ if (typeof warnAt !== 'number' || !Number.isFinite(warnAt) || warnAt <= 0 || warnAt > 1) {
105
+ throw new MeterInvalidError(name, 'warnAt', 'a fraction of the limit in (0, 1]');
106
+ }
107
+ }
108
+ // Half a pair is a declaration that does nothing: a threshold nobody hears, or a listener
109
+ // nothing ever calls. Refused rather than read as "warn nowhere".
110
+ if ((warnAt === undefined) !== (meter.onWarn === undefined)) {
111
+ throw new MeterInvalidError(name, 'warnAt', 'declared together with onWarn, or neither');
112
+ }
113
+ }
114
+
115
+ export function meterFact(meter: ActionMeter | undefined): ActionMeterFact | null {
116
+ if (meter === undefined) return null;
117
+ return {
118
+ name: meter.name,
119
+ period: meter.period,
120
+ timeZone: 'UTC',
121
+ limit: typeof meter.limit === 'function' ? null : meter.limit,
122
+ warnAt: meter.warnAt ?? null,
123
+ };
124
+ }
package/src/mutator.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  import { action, isAction } from './action';
20
20
  import { MutatorNotIdempotentError } from './errors';
21
21
  import type { ActionHttp } from './http-path';
22
+ import type { ActionMeter } from './meter';
22
23
  import { assertConflictClock } from './mutator-clock';
23
24
  import type { ActionPolicy } from './policy-gate';
24
25
 
@@ -93,10 +94,14 @@ export interface MutatorDef<
93
94
  readonly mcp?: ActionMcp;
94
95
  /** Same key, same meaning as an action's — a mutator IS an action. */
95
96
  readonly rateLimit?: ActionRateLimit;
97
+ /** Same key, same meaning as an action's: a replayed write draws nothing. */
98
+ readonly meter?: ActionMeter;
96
99
  /** Same key, same meaning as an action's: `Deprecation`/`Sunset` on every response. */
97
100
  readonly deprecated?: Deprecation;
98
101
  /** Same key, same meaning as an action's: `http: { path }` pins the URL. */
99
102
  readonly http?: ActionHttp;
103
+ /** Same key, same meaning as an action's: the refusals `server` may throw, closed. */
104
+ readonly errors?: readonly string[];
100
105
  /**
101
106
  * REQUIRED, and only `true`. A mutator's write is replayed under one `Idempotency-Key` —
102
107
  * `useMutation`'s retry, the offline queue's drain after a dropped response — and the server
@@ -176,8 +181,10 @@ export function mutator<
176
181
  ...(def.cache === undefined ? {} : { cache: def.cache }),
177
182
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
178
183
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
184
+ ...(def.meter === undefined ? {} : { meter: def.meter }),
179
185
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
180
186
  ...(def.http === undefined ? {} : { http: def.http }),
187
+ ...(def.errors === undefined ? {} : { errors: def.errors }),
181
188
  ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
182
189
  idempotent: true,
183
190
  ...(def.audit === undefined ? {} : { audit: def.audit }),
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The COMPLETE document an app opts into with `defineApi({ openapi })`: its own `info` and
3
- * `servers`, the security schemes its routes are reached through, and the two refusals every
4
- * authenticated operation can answer — 401 and 429, with `Retry-After` — which the per-primitive
5
- * projections cannot state because they do not know the route an operation is mounted as. Plus
6
- * the second document a bearer mount publishes: only the cut, at the mounted paths.
3
+ * `servers`, the security schemes its routes are reached through, and the refusals an
4
+ * authenticated, limited or metered operation can answer — 401 and 429, with `Retry-After` — which
5
+ * the per-primitive projections cannot state: they do not know the route an operation is mounted
6
+ * as. Plus the second document a bearer mount publishes: only the cut, at the mounted paths.
7
7
  *
8
8
  * Built over the merged document (actions AND reads — `@ultimat3/cli` merges the two packages'
9
9
  * paths) and over the `Route`s that serve it, so "is this operation authenticated" is the answer
@@ -11,6 +11,7 @@
11
11
  * inputs are sorted and nothing reads the clock or the environment.
12
12
  */
13
13
 
14
+ import { toOpenApiPath } from '@ultimat3/core';
14
15
  import type { Route } from '@ultimat3/http';
15
16
  import { mountedPath } from '@ultimat3/http';
16
17
  import type { ApiMount, ApiOpenApi } from './api-declaration';
@@ -62,9 +63,11 @@ function problem(description: string, headers?: Record<string, unknown>): Record
62
63
 
63
64
  const lower = (method: string): string => method.toLowerCase();
64
65
 
65
- /** `method path` → the route that serves it. */
66
+ /** `method path` → the route that serves it, the path in the document's `{param}` spelling. */
66
67
  function routeIndex(routes: readonly Route[]): ReadonlyMap<string, Route> {
67
- return new Map(routes.map((route) => [`${lower(route.method)} ${route.path}`, route]));
68
+ return new Map(
69
+ routes.map((route) => [`${lower(route.method)} ${toOpenApiPath(route.path)}`, route]),
70
+ );
68
71
  }
69
72
 
70
73
  interface Refusals {
@@ -85,12 +88,34 @@ interface Refusals {
85
88
  * still does, hence both reads above.
86
89
  */
87
90
  function declaresRateLimit(operation: Operation): boolean {
91
+ return declares(operation, 'rateLimit');
92
+ }
93
+
94
+ /**
95
+ * A `meter:` is drawn inside `invoke` too, on every surface, and refuses with `X_METER_EXCEEDED`
96
+ * and a `Retry-After` up to the period's end — on a public operation as on an authenticated one.
97
+ * Published as `x-ultimate.meter`, absent when undeclared.
98
+ */
99
+ function declaresMeter(operation: Operation): boolean {
100
+ return declares(operation, 'meter');
101
+ }
102
+
103
+ function declares(operation: Operation, key: 'rateLimit' | 'meter'): boolean {
88
104
  const extensions = operation['x-ultimate'];
89
105
  if (typeof extensions !== 'object' || extensions === null) return false;
90
- const declared = (extensions as Record<string, unknown>)['rateLimit'];
106
+ const declared = (extensions as Record<string, unknown>)[key];
91
107
  return declared !== undefined && declared !== null;
92
108
  }
93
109
 
110
+ /** The codes a 429 can carry here, named so a client knows which refusal it is reading. */
111
+ function tooManyDescription(throttled: boolean, metered: boolean): string {
112
+ const codes = [
113
+ ...(throttled ? ['X_RATE_LIMITED'] : []),
114
+ ...(metered ? ['X_METER_EXCEEDED'] : []),
115
+ ].join(' or ');
116
+ return `${codes} — retry after Retry-After seconds`;
117
+ }
118
+
94
119
  /** One operation, with its route's refusals and security added. Never mutates `operation`. */
95
120
  function completeOperation(operation: Operation, route: Route | undefined, refusals: Refusals) {
96
121
  const authenticated = route?.meta.auth === 'required';
@@ -98,7 +123,8 @@ function completeOperation(operation: Operation, route: Route | undefined, refus
98
123
  refusals.everyOpLimited ||
99
124
  route?.meta.rateLimitBucket !== undefined ||
100
125
  declaresRateLimit(operation);
101
- if (!authenticated && !limited) return operation;
126
+ const metered = declaresMeter(operation);
127
+ if (!authenticated && !limited && !metered) return operation;
102
128
  const responses = { ...(operation['responses'] as Record<string, unknown>) };
103
129
  if (limited) {
104
130
  const ok = responses['200'] as Record<string, unknown> | undefined;
@@ -118,8 +144,9 @@ function completeOperation(operation: Operation, route: Route | undefined, refus
118
144
  refusals.unauthorizedHeaders,
119
145
  );
120
146
  }
147
+ // A meter is no bucket: its refusal states the delay to the period's end and no `RateLimit-*`.
121
148
  responses['429'] = problem(
122
- 'X_RATE_LIMITED — retry after Retry-After seconds',
149
+ tooManyDescription(authenticated || limited, metered),
123
150
  limited ? { ...RETRY_AFTER_HEADER, ...RATE_LIMIT_HEADERS } : RETRY_AFTER_HEADER,
124
151
  );
125
152
  return {
@@ -237,8 +264,10 @@ export function mountOpenApi(
237
264
  const scope = scopeOf.get(route.meta.name);
238
265
  if (scope === undefined) continue;
239
266
  const method = lower(route.method);
267
+ // The document spells a `:param` template `{param}`; the route table keeps the router's form.
268
+ const documented = toOpenApiPath(route.path);
240
269
  const item = (
241
- Object.hasOwn(document.paths, route.path) ? document.paths[route.path] : undefined
270
+ Object.hasOwn(document.paths, documented) ? document.paths[documented] : undefined
242
271
  ) as Record<string, Operation> | undefined;
243
272
  const operation = item?.[method];
244
273
  if (operation === undefined) continue;
@@ -261,7 +290,7 @@ export function mountOpenApi(
261
290
  const extensions = (completed['x-ultimate'] as Record<string, unknown> | undefined) ?? {};
262
291
  for (const tag of (completed['tags'] as readonly string[] | undefined) ?? []) tags.add(tag);
263
292
  unlocks.set(scope, [...(unlocks.get(scope) ?? []), route.meta.name]);
264
- const mounted = mountedPath(mount.prefix, route.path);
293
+ const mounted = toOpenApiPath(mountedPath(mount.prefix, route.path));
265
294
  mountedItems.set(mounted, {
266
295
  ...mountedItems.get(mounted),
267
296
  [method]: { ...completed, responses, 'x-ultimate': { ...extensions, scope } },
@@ -0,0 +1,18 @@
1
+ // Single responsibility: an action operation's `responses` with its declared `errors:` in them —
2
+ // core's builder, bound to `@ultimat3/http`'s status table and Problem `type`, and to this
3
+ // document's `Problem` component. Undeclared, the members are today's.
4
+
5
+ import { withDeclaredResponses } from '@ultimat3/core';
6
+ import { declaredStatusFor, problemTypeFor } from '@ultimat3/http';
7
+ import { PROBLEM_SCHEMA_NAME, schemaRef } from './naming';
8
+
9
+ export function withDeclaredErrors(
10
+ errors: readonly string[] | undefined,
11
+ responses: Readonly<Record<string, unknown>>,
12
+ ): Record<string, unknown> {
13
+ return withDeclaredResponses(responses, errors, {
14
+ statusOf: declaredStatusFor,
15
+ typeFor: problemTypeFor,
16
+ problemRef: schemaRef(PROBLEM_SCHEMA_NAME),
17
+ });
18
+ }