@ultimat3/action 29.4.0 → 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.
- package/CLAUDE.md +68 -101
- package/README.md +151 -9
- package/package.json +19 -8
- package/src/action-stream.ts +116 -0
- package/src/action.ts +116 -13
- package/src/audit-subject-target.ts +81 -0
- package/src/client.ts +102 -12
- package/src/contract-test.ts +40 -16
- package/src/declared-errors-contract.ts +38 -0
- package/src/declared-errors.ts +13 -0
- package/src/error-titles.ts +18 -2
- package/src/errors-http.ts +19 -2
- package/src/errors-idempotency.ts +35 -0
- package/src/errors-meter.ts +48 -0
- package/src/errors-stream.ts +18 -0
- package/src/errors.ts +20 -12
- package/src/exercise-errors.ts +74 -0
- package/src/exercise.ts +153 -0
- package/src/facade.ts +1 -1
- package/src/http-path.ts +102 -20
- package/src/http-stream.ts +99 -0
- package/src/http.ts +106 -38
- package/src/idempotency.ts +108 -3
- package/src/index.ts +74 -3
- package/src/invoke.ts +103 -27
- package/src/json-schema.ts +1 -1
- package/src/meter-gate.ts +103 -0
- package/src/meter-memory.ts +124 -0
- package/src/meter-period.ts +34 -0
- package/src/meter-postgres.ts +132 -0
- package/src/meter-store.ts +61 -0
- package/src/meter.ts +124 -0
- package/src/mutator.ts +7 -0
- package/src/openapi-complete.ts +40 -11
- package/src/openapi-errors.ts +18 -0
- package/src/openapi.ts +39 -4
- package/src/rate-limit-gate.ts +43 -3
- package/src/registry.ts +39 -18
- package/src/tool-gate.ts +97 -0
- package/src/tx-scope.ts +71 -3
- package/src/validate.ts +1 -1
- package/src/wire-issues.ts +4 -1
- package/src/sample-input.ts +0 -226
|
@@ -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 }),
|
package/src/openapi-complete.ts
CHANGED
|
@@ -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
|
|
4
|
-
* authenticated operation can answer — 401 and 429, with `Retry-After` — which
|
|
5
|
-
* projections cannot state
|
|
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(
|
|
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>)[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|