diff --git a/apps/ui/scripts/render-og-preview.ts b/apps/ui/scripts/render-og-preview.ts index 76c183215c..ec8ad1f765 100644 --- a/apps/ui/scripts/render-og-preview.ts +++ b/apps/ui/scripts/render-og-preview.ts @@ -27,6 +27,7 @@ import fs from "node:fs"; import path from "node:path"; import { Resvg } from "@resvg/resvg-js"; +import type { ReactNode } from "react"; import satori from "satori"; import { html } from "satori-html"; import { glyphsForMarkup, renderCardMarkup } from "../src/lib/og-image.ts"; @@ -158,7 +159,13 @@ for (const [name, variant] of Object.entries(VARIANTS)) { loadFont("Inter", 400, glyphs), ]); - const svg = await satori(html(markup) as never, { + // satori-html returns satori's own `VNode`; satori's published signature says + // `ReactNode` because React is its reference renderer. Same runtime object, + // two packages that do not declare each other -- stated once, narrowly. + // `as ReactNode` and not `as never`: the latter also accepted `undefined`, + // which is what a renderCardMarkup returning nothing would have handed over. + // Mirrors scripts/refresh-og-image.ts, which renders the same card in CI. + const svg = await satori(html(markup) as ReactNode, { width: 1200, height: 630, fonts: [ diff --git a/apps/ui/src/components/metagraphed/analytics/drift-activity.tsx b/apps/ui/src/components/metagraphed/analytics/drift-activity.tsx index a3aa183f6c..fe0856fabf 100644 --- a/apps/ui/src/components/metagraphed/analytics/drift-activity.tsx +++ b/apps/ui/src/components/metagraphed/analytics/drift-activity.tsx @@ -6,6 +6,7 @@ import { classNames } from "@/lib/metagraphed/format"; import { TimeAgo } from "@jsonbored/ui-kit"; import { StateBlock } from "@/components/metagraphed/states/state-block"; import { Panel } from "@/components/metagraphed/primitives"; +import { readNumber } from "@/lib/metagraphed/read-key"; interface Props { schemas: SchemaInfo[]; @@ -304,10 +305,9 @@ function weight(s: SchemaInfo): number { } function numericField(s: SchemaInfo, keys: string[]): number | null { - const rec = s as unknown as Record; for (const k of keys) { - const v = rec[k]; - if (typeof v === "number" && Number.isFinite(v)) return v; + const v = readNumber(s, k); + if (v !== undefined) return v; } return null; } diff --git a/apps/ui/src/components/metagraphed/endpoint-detail-drawer.tsx b/apps/ui/src/components/metagraphed/endpoint-detail-drawer.tsx index 4c67abb649..dcff14f7e2 100644 --- a/apps/ui/src/components/metagraphed/endpoint-detail-drawer.tsx +++ b/apps/ui/src/components/metagraphed/endpoint-detail-drawer.tsx @@ -37,11 +37,12 @@ export function EndpointDetailDrawer({ // Filter: state (down/warn/other), and pool membership (this endpoint's pool). const [stateFilter, setStateFilter] = useState<"all" | "down" | "warn" | "other">("all"); const [poolOnly, setPoolOnly] = useState(false); - const endpointPoolId = String( - (endpoint as unknown as { pool_id?: string; pool?: string }).pool_id ?? - (endpoint as unknown as { pool_id?: string; pool?: string }).pool ?? - "", - ); + // No assertion needed for either: `pool` is on the Endpoint contract, and + // `pool_id` -- which is not -- arrives through its index signature as + // `unknown`, which `String()` accepts. The old cast restated both as + // optional strings, so it was claiming a contract field that does not exist + // rather than reading one that might. + const endpointPoolId = String(endpoint.pool_id ?? endpoint.pool ?? ""); const rows = useMemo(() => { let list = allRows; diff --git a/apps/ui/src/components/metagraphed/hero-feature-row.tsx b/apps/ui/src/components/metagraphed/hero-feature-row.tsx index 37b6d4bd8a..0aab299bac 100644 --- a/apps/ui/src/components/metagraphed/hero-feature-row.tsx +++ b/apps/ui/src/components/metagraphed/hero-feature-row.tsx @@ -14,6 +14,7 @@ import { import { formatNumber } from "@/lib/metagraphed/format"; import type { ChainActivity, Subnet } from "@/lib/metagraphed/types"; import { useHydrated } from "@/hooks/use-hydrated"; +import { readNumber, readString } from "@/lib/metagraphed/read-key"; /** * The UTC calendar-day string (YYYY-MM-DD) "today" means for this card, @@ -292,14 +293,10 @@ function pickFeatured(subnets: Subnet[], n: number): Subnet[] { // participant count as a rough popularity proxy. Skip root (netuid 0). const app = subnets.filter((s) => s.netuid > 0); const score = (s: Subnet) => { - const c = (s as unknown as { curation?: string }).curation ?? ""; + const c = readString(s, "curation") ?? ""; const curationRank = c === "adapter" ? 4 : c === "native" ? 3 : c === "verified" ? 3 : c === "pilot" ? 2 : 1; - const size = Number( - (s as unknown as { participants?: number }).participants ?? - (s as unknown as { neuron_count?: number }).neuron_count ?? - 0, - ); + const size = Number(readNumber(s, "participants") ?? readNumber(s, "neuron_count") ?? 0); return curationRank * 1e6 + size; }; return [...app].sort((a, b) => score(b) - score(a)).slice(0, n); diff --git a/apps/ui/src/components/metagraphed/network-parameters-panel.tsx b/apps/ui/src/components/metagraphed/network-parameters-panel.tsx index 89e7c1d0e5..085f8f70ba 100644 --- a/apps/ui/src/components/metagraphed/network-parameters-panel.tsx +++ b/apps/ui/src/components/metagraphed/network-parameters-panel.tsx @@ -9,6 +9,7 @@ import { import { networkParametersQuery } from "@/lib/metagraphed/queries"; import { formatNumber, formatTao } from "@/lib/metagraphed/format"; import type { NetworkParameters } from "@/lib/metagraphed/types"; +import { readKey } from "@/lib/metagraphed/read-key"; type ParameterKind = "percent" | "tao" | "count" | "raw"; @@ -66,7 +67,6 @@ export interface ParameterGroup { } export function buildParameterGroups(parameters: NetworkParameters): ParameterGroup[] { - const raw = parameters as unknown as Record; const consumed = new Set(["queried_at"]); const groups: ParameterGroup[] = PARAMETER_GROUPS.map((group) => ({ label: group.label, @@ -78,12 +78,12 @@ export function buildParameterGroups(parameters: NetworkParameters): ParameterGr label: meta.label, hint: meta.hint, kind: meta.kind, - value: raw[key] ?? null, + value: readKey(parameters, key) ?? null, }; }), })); - const leftoverKeys = Object.keys(raw).filter((key) => !consumed.has(key)); + const leftoverKeys = Object.keys(parameters).filter((key) => !consumed.has(key)); if (leftoverKeys.length > 0) { groups.push({ label: "Other", @@ -91,7 +91,7 @@ export function buildParameterGroups(parameters: NetworkParameters): ParameterGr key, label: key, kind: "raw" as const, - value: raw[key] ?? null, + value: readKey(parameters, key) ?? null, })), }); } diff --git a/apps/ui/src/components/metagraphed/resource-explorer.tsx b/apps/ui/src/components/metagraphed/resource-explorer.tsx index 61af516cc9..e89a6c8b79 100644 --- a/apps/ui/src/components/metagraphed/resource-explorer.tsx +++ b/apps/ui/src/components/metagraphed/resource-explorer.tsx @@ -36,6 +36,7 @@ import { type Severity, } from "@/components/metagraphed/subnet-filter-context"; import type { Endpoint, RpcPool, Surface } from "@/lib/metagraphed/types"; +import { readString } from "@/lib/metagraphed/read-key"; type Seg = "endpoints" | "surfaces" | "schemas"; @@ -529,9 +530,7 @@ function SurfacesView({ } const rows = filter.isAll ? allRows - : allRows.filter((s) => - filter.isActive(((s as unknown as { health?: string }).health ?? "unknown") as Severity), - ); + : allRows.filter((s) => filter.isActive((readString(s, "health") ?? "unknown") as Severity)); const hidden = allRows.length - rows.length; if (allRows.length === 0) { return ( diff --git a/apps/ui/src/components/metagraphed/schema-drift-detail.tsx b/apps/ui/src/components/metagraphed/schema-drift-detail.tsx index 838af3cc01..01b99fae1b 100644 --- a/apps/ui/src/components/metagraphed/schema-drift-detail.tsx +++ b/apps/ui/src/components/metagraphed/schema-drift-detail.tsx @@ -15,6 +15,7 @@ import { SchemaSnapshotSummary } from "@/components/metagraphed/schema-snapshot- import { useCopy } from "@/hooks/use-copy"; import { formatFreshness, formatFreshnessAbsolute } from "@/lib/metagraphed/freshness"; import type { SchemaInfo } from "@/lib/metagraphed/types"; +import { readKey, readString } from "@/lib/metagraphed/read-key"; interface Props { schema: SchemaInfo | null; @@ -156,23 +157,20 @@ function EvidenceSection({ copied: boolean; onCopy: (v: string) => void; }) { - const rec = schema as unknown as Record; const links: Array<{ label: string; href: string }> = []; for (const key of ["url", "snapshot_url", "prev_snapshot_url", "artifact_path"]) { - const v = rec[key]; - if (typeof v === "string" && v.length > 0) { + const v = readString(schema, key); + if (v !== undefined && v.length > 0) { links.push({ label: key.replace(/_/g, " "), href: v }); } } - const evidence = rec.evidence; + const evidence = readKey(schema, "evidence"); if (Array.isArray(evidence)) { - for (const e of evidence) { - const u = (e as Record)?.url; - if (typeof u === "string" && u.startsWith("http")) { - links.push({ - label: String((e as Record)?.source ?? "evidence"), - href: u, - }); + for (const entry of evidence) { + if (typeof entry !== "object" || entry === null) continue; + const url = readString(entry, "url"); + if (url?.startsWith("http")) { + links.push({ label: readString(entry, "source") ?? "evidence", href: url }); } } } diff --git a/apps/ui/src/lib/metagraphed/chain-connection.test.ts b/apps/ui/src/lib/metagraphed/chain-connection.test.ts index 3373b4ce40..d1abc58940 100644 --- a/apps/ui/src/lib/metagraphed/chain-connection.test.ts +++ b/apps/ui/src/lib/metagraphed/chain-connection.test.ts @@ -12,6 +12,7 @@ import { buildExtrinsic, getNextNonce, getCurrentBlock, + getFreeBalance, getMaxDelegateTake, getMinDelegateTake, getTxDelegateTakeRateLimit, @@ -197,6 +198,41 @@ describe("live delegate-take bounds/rate-limit queries", () => { return { api, delegates, lastRateLimitedBlock }; } + // These three pin what the old `api as unknown as SubtensorQueryApi` could + // not: the assertion named five storage entries and claimed `.toNumber()` on + // every result, so a renamed entry or a retyped codec compiled fine and blew + // up as a bare TypeError inside a wallet flow -- after the user had already + // signed off on an amount. + + it("names the entry when the pallet no longer has it, instead of TypeError", async () => { + const api = { query: { subtensorModule: {} } } as unknown as ApiPromise; + await expect(getMaxDelegateTake(api)).rejects.toThrow(/maxDelegateTake is not a storage entry/); + }); + + it("names the entry when its codec stops being numeric", async () => { + // A u16 that became a struct, an Option, a Bytes -- anything without + // toNumber(). The old code called it regardless. + const api = { + query: { + subtensorModule: { + minDelegateTake: vi.fn(async () => ({ toHuman: () => "0" })), + }, + }, + } as unknown as ApiPromise; + await expect(getMinDelegateTake(api)).rejects.toThrow( + /minDelegateTake returned a codec with no toNumber/, + ); + }); + + it("rejects a system.account with no free balance rather than reading undefined", async () => { + const api = { + query: { system: { account: vi.fn(async () => ({ nonce: 1 })) } }, + } as unknown as ApiPromise; + await expect(getFreeBalance(api, "5Coldkey")).rejects.toThrow( + /AccountInfo with a free balance/, + ); + }); + it("getMaxDelegateTake returns the live-confirmed 18% bound (11796 parts)", async () => { const { api } = makeQueryApi({ maxDelegateTake: 11_796 }); await expect(getMaxDelegateTake(api)).resolves.toBe(11_796); diff --git a/apps/ui/src/lib/metagraphed/chain-connection.ts b/apps/ui/src/lib/metagraphed/chain-connection.ts index 2f62e7bf09..bf3f307542 100644 --- a/apps/ui/src/lib/metagraphed/chain-connection.ts +++ b/apps/ui/src/lib/metagraphed/chain-connection.ts @@ -16,6 +16,7 @@ import type { ApiPromise } from "@polkadot/api"; import type { SubmittableExtrinsic } from "@polkadot/api/types"; +import type { Codec } from "@polkadot/types-codec/types"; import type { AddStakeLimitParams, RemoveStakeLimitParams, @@ -68,11 +69,23 @@ export async function getApi(endpoint: string = DEFAULT_RPC_ENDPOINT): Promise) implements; these two narrow, local -// interfaces name only the one method/field each call site actually uses, -// rather than reaching for a blanket `any` that would silently swallow a -// genuine shape mismatch elsewhere in the same expression. +// fixable from this repo. +// +// What IS fixable is how that gap is crossed. This file used to assert the +// whole `ApiPromise` into a hand-written `SubtensorQueryApi` naming five +// storage entries, once per call site. That restated the pallet surface in +// TypeScript and then trusted the restatement: a renamed or removed entry +// still compiled, and `undefined()` is a TypeError inside a wallet flow, at +// the point a user has already signed off on an amount. Worse, `.toNumber()` +// was claimed on the RESULT -- so a storage entry whose type changed from u16 +// to something without `toNumber` also compiled. +// +// `api.query` carries a real index signature, so every entry below reads with +// no assertion at all and comes back as `Codec`, which is the truth. The +// codec is then narrowed by a PREDICATE whose body actually performs the +// check -- `"toNumber" in value && typeof value.toNumber === "function"` -- +// so the claim and the check are the same statement. A shape that does not +// match raises a named error instead of a bare TypeError. interface BigIntCodec { toBigInt(): bigint; } @@ -82,6 +95,52 @@ interface AccountInfoCodec { interface NumberCodec { toNumber(): number; } + +function isNumberCodec(value: Codec): value is Codec & NumberCodec { + return "toNumber" in value && typeof value.toNumber === "function"; +} + +function isBigIntCodec(value: Codec): value is Codec & BigIntCodec { + return "toBigInt" in value && typeof value.toBigInt === "function"; +} + +function isAccountInfoCodec(value: Codec): value is Codec & AccountInfoCodec { + if (!("data" in value)) return false; + const data = value.data; + if (typeof data !== "object" || data === null || !("free" in data)) return false; + const free = data.free; + return ( + typeof free === "object" && + free !== null && + "toBigInt" in free && + typeof free.toBigInt === "function" + ); +} + +/** One `subtensorModule` storage entry, read through @polkadot/api's own index + * signature and returned as the number it is -- or a named failure. */ +async function subtensorNumber( + api: ApiPromise, + entry: string, + ...args: unknown[] +): Promise { + const read = api.query.subtensorModule[entry]; + if (typeof read !== "function") { + throw new Error( + `subtensorModule.${entry} is not a storage entry on this runtime -- the ` + + `pallet's surface changed, or the metadata did not load.`, + ); + } + const raw = await read(...args); + if (!isNumberCodec(raw)) { + throw new Error( + `subtensorModule.${entry} returned a codec with no toNumber(); its ` + + `on-chain type is no longer numeric.`, + ); + } + return raw.toNumber(); +} + // LastRateLimitedBlock is StorageMap, u64> -- // a single generic map keyed by an enum whose exact shape subtensor's own // on-chain metadata supplies at connection time (RateLimitKey isn't a @@ -90,17 +149,6 @@ interface NumberCodec { // (2026-07-15) that passing the enum as a plain `{ VariantName: value }` // object -- the same shape @polkadot/api accepts for any enum-typed query // argument once the runtime metadata describes it -- resolves correctly. -interface SubtensorQueryApi { - query: { - subtensorModule: { - maxDelegateTake(): Promise; - minDelegateTake(): Promise; - txDelegateTakeRateLimit(): Promise; - delegates(hotkey: string): Promise; - lastRateLimitedBlock(key: { LastTxBlockDelegateTake: string }): Promise; - }; - }; -} /** * The network's live minimum stake floor (rao), read from the pallet's own @@ -111,13 +159,19 @@ interface SubtensorQueryApi { * a valid one -- querying the real value has no such drift risk. */ export async function getMinStake(api: ApiPromise): Promise { - const raw = api.consts.subtensorModule.initialMinStake as unknown as BigIntCodec; + const raw = api.consts.subtensorModule.initialMinStake; + if (!isBigIntCodec(raw)) { + throw new Error("subtensorModule.initialMinStake is not a numeric constant"); + } return asRao(raw.toBigInt()); } /** The coldkey's spendable free balance (rao) -- for validateStakeInputs' availableBalanceRao. */ export async function getFreeBalance(api: ApiPromise, coldkeySs58: string): Promise { - const account = (await api.query.system.account(coldkeySs58)) as unknown as AccountInfoCodec; + const account = await api.query.system.account(coldkeySs58); + if (!isAccountInfoCodec(account)) { + throw new Error("system.account did not return an AccountInfo with a free balance"); + } return asRao(account.data.free.toBigInt()); } @@ -148,20 +202,15 @@ export async function getCurrentBlock(api: ApiPromise): Promise { * getMinStake, since governance could change any of these post-genesis. */ export async function getMaxDelegateTake(api: ApiPromise): Promise { - const raw = await (api as unknown as SubtensorQueryApi).query.subtensorModule.maxDelegateTake(); - return raw.toNumber(); + return subtensorNumber(api, "maxDelegateTake"); } export async function getMinDelegateTake(api: ApiPromise): Promise { - const raw = await (api as unknown as SubtensorQueryApi).query.subtensorModule.minDelegateTake(); - return raw.toNumber(); + return subtensorNumber(api, "minDelegateTake"); } export async function getTxDelegateTakeRateLimit(api: ApiPromise): Promise { - const raw = await ( - api as unknown as SubtensorQueryApi - ).query.subtensorModule.txDelegateTakeRateLimit(); - return raw.toNumber(); + return subtensorNumber(api, "txDelegateTakeRateLimit"); } /** @@ -179,8 +228,7 @@ export async function getTxDelegateTakeRateLimit(api: ApiPromise): Promise { - const raw = await (api as unknown as SubtensorQueryApi).query.subtensorModule.delegates(hotkey); - return raw.toNumber(); + return subtensorNumber(api, "delegates", hotkey); } /** @@ -191,10 +239,9 @@ export async function getCurrentTakeParts(api: ApiPromise, hotkey: string): Prom * isDelegateTakeRateLimited's own "never limited" sentinel. */ export async function getLastTxBlockDelegateTake(api: ApiPromise, hotkey: string): Promise { - const raw = await ( - api as unknown as SubtensorQueryApi - ).query.subtensorModule.lastRateLimitedBlock({ LastTxBlockDelegateTake: hotkey }); - return raw.toNumber(); + return subtensorNumber(api, "lastRateLimitedBlock", { + LastTxBlockDelegateTake: hotkey, + }); } /** diff --git a/apps/ui/src/lib/metagraphed/idle.test.ts b/apps/ui/src/lib/metagraphed/idle.test.ts new file mode 100644 index 0000000000..1c0afa6cfb --- /dev/null +++ b/apps/ui/src/lib/metagraphed/idle.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cancelIdle, requestIdle } from "./idle"; + +// Both branches matter and only one of them is the browser most people use. +// Safari shipped requestIdleCallback in 16.4; lib.dom declares it as always +// present, which is the mismatch the two route files were working around when +// they asserted `window` into a maybe-shape. +function withWindow(stub: Record) { + vi.stubGlobal("window", stub); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("requestIdle", () => { + it("uses requestIdleCallback where the browser has it", () => { + const requestIdleCallback = vi.fn(() => 7); + withWindow({ requestIdleCallback, setTimeout: vi.fn(() => 99) }); + const callback = () => {}; + expect(requestIdle(callback)).toBe(7); + expect(requestIdleCallback).toHaveBeenCalledWith(callback); + }); + + it("falls back to a macrotask where it does not", () => { + const setTimeout = vi.fn(() => 99); + withWindow({ setTimeout }); + const callback = () => {}; + expect(requestIdle(callback)).toBe(99); + // 1ms and not 0: this exists to get work off the critical path, and a 0ms + // timeout on a busy main thread lands in the frame that scheduled it. + expect(setTimeout).toHaveBeenCalledWith(callback, 1); + }); +}); + +describe("cancelIdle", () => { + it("cancels through cancelIdleCallback where the browser has it", () => { + const cancelIdleCallback = vi.fn(); + const clearTimeout = vi.fn(); + withWindow({ cancelIdleCallback, clearTimeout }); + cancelIdle(7); + expect(cancelIdleCallback).toHaveBeenCalledWith(7); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + + it("clears the timeout where it does not", () => { + const clearTimeout = vi.fn(); + withWindow({ clearTimeout }); + cancelIdle(99); + expect(clearTimeout).toHaveBeenCalledWith(99); + }); + + it("pairs with requestIdle on the SAME branch", () => { + // The bug this pins: cancelling a setTimeout handle with + // cancelIdleCallback (or the reverse) is silent -- nothing throws, the + // work just never gets cancelled. The two helpers must agree about which + // scheduler produced the handle, and they agree by both asking `window`. + const clearTimeout = vi.fn(); + withWindow({ setTimeout: vi.fn(() => 42), clearTimeout }); + cancelIdle(requestIdle(() => {})); + expect(clearTimeout).toHaveBeenCalledWith(42); + }); +}); diff --git a/apps/ui/src/lib/metagraphed/idle.ts b/apps/ui/src/lib/metagraphed/idle.ts new file mode 100644 index 0000000000..0f916993ac --- /dev/null +++ b/apps/ui/src/lib/metagraphed/idle.ts @@ -0,0 +1,49 @@ +/** + * `requestIdleCallback` where the browser has it, a macrotask where it does + * not. + * + * ## Why this is not a cast + * + * `lib.dom` declares `requestIdleCallback` as always present on `Window`. + * Safari only shipped it in 16.4, so two route files were written not to + * trust that declaration -- and expressed the doubt by asserting `window` into + * `{ requestIdleCallback?: (cb: () => void) => number }`, which is a bigger + * claim than it looks: it also restates the signature, so the day the callback + * gains its `IdleDeadline` argument or the return type changes, the assertion + * keeps compiling and the call is wrong. + * + * Taking the reference AS a maybe-shape says the same thing without claiming + * anything. A real `Window` is assignable to `Partial>` + * because every property it has is one the target allows -- so no assertion is + * needed, and the signatures stay lib.dom's rather than a copy of them. + * + * ## Why it is shared + * + * -providers-index-page.tsx and -subnets-index-page.tsx had the same eight + * lines each, four assertions between them. Both prefetch brand icons for a + * long list once the browser is idle, and neither wants to be the one that + * gets fixed when the fallback is wrong. + */ +type MaybeIdle = Partial>; + +/** + * Run `callback` when the browser is idle. Returns a handle for `cancelIdle`. + * + * The fallback is `setTimeout(..., 1)` and not `0`: this exists to get work + * OFF the critical path, and a 0ms timeout on a busy main thread still lands + * in the same frame it was scheduled from. + */ +export function requestIdle(callback: () => void): number { + const w: MaybeIdle = window; + return w.requestIdleCallback ? w.requestIdleCallback(callback) : window.setTimeout(callback, 1); +} + +/** Cancel a handle from `requestIdle`, whichever scheduler produced it. */ +export function cancelIdle(handle: number): void { + const w: MaybeIdle = window; + if (w.cancelIdleCallback) { + w.cancelIdleCallback(handle); + return; + } + window.clearTimeout(handle); +} diff --git a/apps/ui/src/lib/metagraphed/read-key.ts b/apps/ui/src/lib/metagraphed/read-key.ts new file mode 100644 index 0000000000..16d865b28c --- /dev/null +++ b/apps/ui/src/lib/metagraphed/read-key.ts @@ -0,0 +1,50 @@ +/** + * Read a dynamic key off a typed object, as `unknown`. + * + * ## The problem this replaces + * + * Nine places in this workspace wrote `x as unknown as Record` + * so they could look up a key computed at runtime -- a sort column, a + * parameter name, a "try these four fields in order" fallback. TypeScript + * refuses the direct index because an INTERFACE has no implicit index + * signature (a `type` alias for the same object shape does), which is a + * soundness concession about interfaces being open to declaration merging, + * not a statement that the read is dangerous. + * + * The workaround was worse than the problem. `as unknown as Record` erases the object's real type, so every OTHER read through the + * same alias also stopped being checked -- and several call sites then read a + * known field through it and got `unknown` back, which they cast again. + * + * ## Why `Reflect.get` + * + * It is the language's own answer and it needs no assertion: `Reflect.get` + * takes `object` and returns `any`, which narrows to `unknown` on the way out. + * No cast, no copy of the object, and the argument stays its real type, so + * every static read at the call site keeps being checked. + * + * `Object.fromEntries(Object.entries(x))` would also type cleanly, but it + * allocates a copy per call -- these run inside table-cell renders and sort + * comparators. + */ +export function readKey(value: object, key: string): unknown { + return Reflect.get(value, key); +} + +/** A dynamic key read as a string, or `undefined` if it is anything else. */ +export function readString(value: object, key: string): string | undefined { + const found = readKey(value, key); + return typeof found === "string" ? found : undefined; +} + +/** + * A dynamic key read as a finite number, or `undefined`. + * + * Finite and not merely `typeof === "number"`: these values come from JSON, + * and a `NaN` reaching a `.toFixed()` renders the string "NaN" into a cell + * rather than the em-dash the absent case is supposed to show. + */ +export function readNumber(value: object, key: string): number | undefined { + const found = readKey(value, key); + return typeof found === "number" && Number.isFinite(found) ? found : undefined; +} diff --git a/apps/ui/src/routes/-gaps-page.tsx b/apps/ui/src/routes/-gaps-page.tsx index e21edad83c..a91cab82b2 100644 --- a/apps/ui/src/routes/-gaps-page.tsx +++ b/apps/ui/src/routes/-gaps-page.tsx @@ -42,6 +42,7 @@ import { classNames } from "@/lib/metagraphed/format"; import { StateBlock } from "@/components/metagraphed/states/state-block"; import type { CurationLevel, Gap, Subnet } from "@/lib/metagraphed/types"; import { MISSING_KINDS, STATUS_OPTIONS, TARGET_OPTIONS, SORT_OPTIONS } from "./contribute"; +import { readKey, readString } from "@/lib/metagraphed/read-key"; // #8304: gap rows rendered before the explicit expander. Module scope so it // is initialised before the component that reads it, not after (a `const` @@ -718,22 +719,22 @@ function GapRow({ // Surface any source/evidence links already on the gap row. Falls back to // the subnet's #evidence deep link so users always have somewhere to go. - const rec = gap as unknown as Record; const rawSources: Array<{ label: string; href: string }> = []; for (const key of ["evidence_url", "source_url", "docs_url", "url"]) { - const v = rec[key]; - if (typeof v === "string" && v.startsWith("http")) { + const v = readString(gap, key); + if (v?.startsWith("http")) { rawSources.push({ label: key.replace("_url", ""), href: v }); } } - const evidence = rec.evidence; + const evidence = readKey(gap, "evidence"); if (Array.isArray(evidence)) { - for (const e of evidence) { - const u = (e as Record)?.url; - if (typeof u === "string" && u.startsWith("http")) { + for (const entry of evidence) { + if (typeof entry !== "object" || entry === null) continue; + const url = readString(entry, "url"); + if (url?.startsWith("http")) { rawSources.push({ - label: String((e as Record)?.source ?? "evidence"), - href: u, + label: readString(entry, "source") ?? "evidence", + href: url, }); } } diff --git a/apps/ui/src/routes/-providers-index-page.tsx b/apps/ui/src/routes/-providers-index-page.tsx index bafc69388d..ede42c2be4 100644 --- a/apps/ui/src/routes/-providers-index-page.tsx +++ b/apps/ui/src/routes/-providers-index-page.tsx @@ -48,6 +48,7 @@ import type { Provider } from "@/lib/metagraphed/types"; import type { ProviderSortKey } from "./apis.providers"; import { providerSortKeys } from "./apis.providers"; import { ApisTabActions } from "./-apis-hub"; +import { cancelIdle, requestIdle } from "@/lib/metagraphed/idle"; export function ProvidersPage() { const search = useSearch({ from: "/apis/providers" }) as ProvidersSearch; @@ -275,10 +276,7 @@ function ProvidersGrid({ view }: { view: "grid" | "table" }) { useEffect(() => { if (typeof window === "undefined") return; - const ric = - (window as unknown as { requestIdleCallback?: (cb: () => void) => number }) - .requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 1)); - const handle = ric(() => { + const handle = requestIdle(() => { for (const p of sorted) prefetchBrandIcon(p.website ?? p.homepage, 36, { iconUrl: p.icon_url, @@ -286,12 +284,7 @@ function ProvidersGrid({ view }: { view: "grid" | "table" }) { lookup: { providerSlug: p.slug }, }); }); - return () => { - const cic = - (window as unknown as { cancelIdleCallback?: (h: number) => void }).cancelIdleCallback ?? - window.clearTimeout; - cic(handle as number); - }; + return () => cancelIdle(handle); }, [sorted]); // Hooks must run unconditionally before the early return below. diff --git a/apps/ui/src/routes/-subnets-index-page.tsx b/apps/ui/src/routes/-subnets-index-page.tsx index efa9bc9315..9c021904f9 100644 --- a/apps/ui/src/routes/-subnets-index-page.tsx +++ b/apps/ui/src/routes/-subnets-index-page.tsx @@ -100,6 +100,8 @@ import { DomainsRollup } from "@/components/metagraphed/domains-rollup"; import { SubnetIndexDirectory } from "@/components/metagraphed/subnet-index-directory"; import type { AgentCatalogSummary, Subnet, SubnetEconomics } from "@/lib/metagraphed/types"; import { useMeasuredRowHeight } from "@/hooks/use-measured-row-height"; +import { cancelIdle, requestIdle } from "@/lib/metagraphed/idle"; +import { readKey, readNumber } from "@/lib/metagraphed/read-key"; // #8248: fetch every active subnet in one shot instead of cursor-paginating -- // the whole list (129 rows) is virtualized client-side, so there is no @@ -803,10 +805,7 @@ function SubnetsTable({ view, density = "comfortable" }: { view: ViewMode; densi // actually changes — not on every keystroke/hover-driven re-render. useEffect(() => { if (typeof window === "undefined") return; - const ric = - (window as unknown as { requestIdleCallback?: (cb: () => void) => number }) - .requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 1)); - const handle = ric(() => { + const handle = requestIdle(() => { for (const s of rows) prefetchBrandIcon(s.website, 32, { iconUrl: s.icon_url, @@ -814,12 +813,7 @@ function SubnetsTable({ view, density = "comfortable" }: { view: ViewMode; densi lookup: { netuid: s.netuid }, }); }); - return () => { - const cic = - (window as unknown as { cancelIdleCallback?: (h: number) => void }).cancelIdleCallback ?? - window.clearTimeout; - cic(handle as number); - }; + return () => cancelIdle(handle); }, [rows]); // Unified QueryBar-driven filter surface. All filter dropdowns become @@ -2138,9 +2132,9 @@ function EmissionCell({ share }: { share?: number }) { function SurfacesCell({ subnet, density = "comfortable" }: { subnet: Subnet; density?: Density }) { const count = subnet.surfaces_count ?? 0; - const rec = subnet as unknown as Record; - const num = (k: string) => (typeof rec[k] === "number" ? (rec[k] as number) : 0); - const byKind = (rec.surfaces_by_kind ?? rec.surface_kinds) as Record | undefined; + const num = (k: string) => readNumber(subnet, k) ?? 0; + const byKind = (readKey(subnet, "surfaces_by_kind") ?? readKey(subnet, "surface_kinds")) as + Record | undefined; // Prefer a real per-kind breakdown if the list API ever exposes one; otherwise // show the surface-trust composition (official / registry-observed / other) — // the list API always carries these counts, so the bar is a meaningful diff --git a/apps/ui/src/routes/-validators-index-page.tsx b/apps/ui/src/routes/-validators-index-page.tsx index 464fe3125c..c29d1e03ec 100644 --- a/apps/ui/src/routes/-validators-index-page.tsx +++ b/apps/ui/src/routes/-validators-index-page.tsx @@ -43,6 +43,7 @@ import { SortHeader, ariaSort, SearchInput } from "@/components/metagraphed/tabl import { TableColGroup } from "@jsonbored/ui-kit"; import type { GlobalValidator } from "@/lib/metagraphed/types"; import { useMeasuredRowHeight } from "@/hooks/use-measured-row-height"; +import { readKey } from "@/lib/metagraphed/read-key"; // #8251: one request for the FULL directory (~1,014 validators live; the API // cap was raised 100 -> 2000 in the same change) — the table body is @@ -169,8 +170,7 @@ function ValidatorsDirectory({ (!search.watched || watchlist.isWatched(v.hotkey)), ); const sorted = sortBy(filtered, sort, order, (row, key) => { - const rec = row as unknown as Record; - return rec[key]; + return readKey(row, key); }); if (watchlist.count === 0) return sorted; const watched: GlobalValidator[] = []; diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts index b4ae8c26e8..9b3be1cf83 100644 --- a/apps/ui/vite.config.ts +++ b/apps/ui/vite.config.ts @@ -278,5 +278,12 @@ export default defineConfig({ } }, }, - } satisfies NitroPluginConfig as unknown as LovableViteTanstackOptions["nitro"], + // `satisfies NitroPluginConfig` is the real check and it runs first: this + // object is validated against nitro's own config type, `hooks` and all. + // The assertion only bridges to the WRAPPER's `nitro` option, which is + // declared as a three-key subset (preset/output/cloudflare) and does not + // admit `hooks` even though nitro does. One hop, not two -- `as unknown as` + // would have discarded the satisfies check's guarantee at the same time, + // so a genuinely malformed nitro config would have compiled. + } satisfies NitroPluginConfig as LovableViteTanstackOptions["nitro"], }); diff --git a/schemas-src/artifact-sections.ts b/schemas-src/artifact-sections.ts index e3a4df9704..4c093e8c8c 100644 --- a/schemas-src/artifact-sections.ts +++ b/schemas-src/artifact-sections.ts @@ -83,5 +83,10 @@ export function sectionsOf(schema: { "would publish a parameter with nothing to select", ); } - return sections as unknown as readonly [string, ...string[]]; + // Destructured, not asserted. `z.enum` wants a NON-EMPTY tuple, and the + // guard above already proves it -- but a cast is how that proof used to + // reach the type, which also switched off checking of the element type + // (#11339). Pulling the head out narrows it with no assertion at all. + const [first, ...rest] = sections; + return [first, ...rest]; } diff --git a/schemas-src/route-queries.ts b/schemas-src/route-queries.ts index 7a4bee4b0e..3c3cf77b20 100644 --- a/schemas-src/route-queries.ts +++ b/schemas-src/route-queries.ts @@ -645,7 +645,7 @@ export const ROUTE_QUERY_SCHEMAS = { // avoid (#10096). Read from VALIDATOR_ECONOMICS_SORTS, the module that // owns it, not a fourth copy. sort: sortSchema( - VALIDATOR_ECONOMICS_SORTS as unknown as [string, ...string[]], + VALIDATOR_ECONOMICS_SORTS, "earning_floor_cost_tao", ).optional(), limit: limitSchema( diff --git a/schemas-src/treasury.ts b/schemas-src/treasury.ts index b3fe2853f6..7e854638c3 100644 --- a/schemas-src/treasury.ts +++ b/schemas-src/treasury.ts @@ -47,6 +47,22 @@ export const TREASURY_REVIEW_STATES = [ ] as const; export const TreasuryReviewStateSchema = z.enum(TREASURY_REVIEW_STATES); +/** + * The states a maintainer may promote INTO. + * + * `candidate` is what the extractor writes; promoting something back to it + * would be undoing a review rather than making one. Derived here rather than + * filtered at the call site so the exclusion travels with the vocabulary it + * excludes from -- a fourth state added above lands in this set automatically, + * which is the behaviour you want, and `.options` still prints the list for a + * usage message. + */ +export const PromotableTreasuryReviewStateSchema = + TreasuryReviewStateSchema.exclude(["candidate"]); +export type PromotableTreasuryReviewState = z.infer< + typeof PromotableTreasuryReviewStateSchema +>; + /** * The citation. `read_at_sha` is required and that is the whole point: a branch * moves under a claim, so the evidence for a finding is the commit that was diff --git a/scripts/artifact-budgets.ts b/scripts/artifact-budgets.ts index 50b854e738..27ec5f0fa5 100644 --- a/scripts/artifact-budgets.ts +++ b/scripts/artifact-budgets.ts @@ -83,7 +83,7 @@ export const ARTIFACT_SIZE_BUDGETS: ArtifactBudget[] = [ const DEFAULT_BUDGET = budget("*", 250_000, 1_000_000); -interface ArtifactSize { +export interface ArtifactSize { path: string; size_bytes: number; } diff --git a/scripts/build-artifacts.ts b/scripts/build-artifacts.ts index 49e24ad3fb..659a5e99c1 100644 --- a/scripts/build-artifacts.ts +++ b/scripts/build-artifacts.ts @@ -109,12 +109,7 @@ import { SCHEMA_CAPTURE_CADENCE_HOURS, SCHEMA_INDEX_R2_KEY, } from "../src/schema-snapshots-sync.ts"; -import { - buildChangelog, - type ArtifactEntry, - type CoverageSnapshot, - type SubnetEntry, -} from "./changelog.ts"; +import { buildChangelog, subnetsOf, type ArtifactEntry } from "./changelog.ts"; import { buildSurfaceAliasArtifact, SURFACE_ALIASES_RELATIVE_PATH, @@ -2946,14 +2941,18 @@ const currentArtifactDigests = await collectArtifactDigests({ // by scripts/build-changelog.ts at publish time against the previous R2 publish. const changelogArtifact: Row = buildChangelog({ contractVersion, - currentArtifacts: currentArtifactDigests as unknown as ArtifactEntry[], - currentCoverage: coverage as unknown as CoverageSnapshot, - currentSubnets: { subnets: subnetIndex as unknown as SubnetEntry[] }, + currentArtifacts: currentArtifactDigests, + currentCoverage: coverage, + currentSubnets: { subnets: subnetIndex }, generatedAt, - previousArtifacts: previousArtifactDigests as unknown as ArtifactEntry[], - previousCoverage: - previousCoverageArtifact as unknown as CoverageSnapshot | null, - previousSubnets: previousSubnetsArtifact, + previousArtifacts: previousArtifactDigests, + previousCoverage: previousCoverageArtifact, + // Read from git, so its shape is a contract with an older deploy -- checked, + // not trusted. At build time this is null and the changelog is the empty + // placeholder; the real diff happens in build-changelog.ts at publish time. + previousSubnets: previousSubnetsArtifact + ? { subnets: subnetsOf(previousSubnetsArtifact) } + : null, }); await writeJson(artifactFile("changelog.json"), changelogArtifact); // Registry-wide summary (R2-tier): homepage/leaderboard stats in one call — @@ -3092,9 +3091,7 @@ const artifactSizes = await collectArtifactSizes({ const reviewArtifactSizes = artifactSizes.filter( (artifact) => artifact.storage_tier !== "r2", ); -const artifactBudgets = evaluateArtifactBudgets( - artifactSizes as unknown as Parameters[0], -); +const artifactBudgets = evaluateArtifactBudgets(artifactSizes); await writeJson(artifactFile("build-summary.json"), { schema_version: 1, contract_version: contractVersion, @@ -5815,7 +5812,7 @@ async function collectPreviousPublicArtifactDigests({ }: { publicRoot: string; r2Root: string; -}): Promise { +}): Promise { const committedArtifacts = await collectCommittedPublicArtifactDigests(); if (committedArtifacts) { return committedArtifacts; @@ -5827,7 +5824,9 @@ async function collectPreviousPublicArtifactDigests({ }); } -async function collectCommittedPublicArtifactDigests(): Promise { +async function collectCommittedPublicArtifactDigests(): Promise< + ArtifactEntry[] | null +> { const publicPrefix = "public/metagraph/"; const output = await gitOutput([ "ls-tree", @@ -5898,7 +5897,7 @@ async function gitBuffer(args: string[]): Promise { encoding: "buffer", maxBuffer: 1024 * 1024 * 50, }); - return stdout as unknown as Buffer; + return stdout; } catch (error) { // git missing (ENOENT) or a "path not in HEAD"/bad-revision error (exit 128, // e.g. an R2-only artifact with no committed baseline). execFileAsync exposes @@ -5922,8 +5921,8 @@ async function collectArtifactDigests({ previousManifest?: Row | null; publicRoot: string; r2Root: string; -}): Promise { - const files: Row[] = []; +}): Promise { + const files: ArtifactEntry[] = []; await collectArtifactFiles( { includeR2Root, publicRoot, r2Root }, async (filePath, root) => { @@ -5976,14 +5975,28 @@ async function readOptionalJson(filePath: string): Promise { } } +/** + * One artifact as the size pass measures it. A `type` and not an `interface` + * ON PURPOSE: an interface has no implicit index signature, so it would not be + * assignable to the `Row[]` the tier-counting helpers below take, and naming + * the shape would cost an assertion at each of them -- which is roughly how + * the one this replaces came to exist. + */ +type ArtifactSizeEntry = { + path: string; + sha256: string; + size_bytes: number; + storage_tier: string; +}; + async function collectArtifactSizes({ publicRoot, r2Root, }: { publicRoot: string; r2Root: string; -}): Promise { - const files: Row[] = []; +}): Promise { + const files: ArtifactSizeEntry[] = []; await collectArtifactFiles({ publicRoot, r2Root }, async (filePath, root) => { if (!filePath.endsWith(".json")) { return; diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 485279efbe..026bd5eb96 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -15,12 +15,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; -import { - buildChangelog, - type ArtifactEntry, - type CoverageSnapshot, - type SubnetEntry, -} from "./changelog.ts"; +import { buildChangelog, subnetsOf, type ArtifactEntry } from "./changelog.ts"; import { artifactFilePath, readJson, repoRoot, writeJson } from "./lib.ts"; import { R2_STAGING_RELATIVE_ROOT } from "../src/artifact-storage.ts"; @@ -122,17 +117,12 @@ async function main(): Promise { contractVersion: placeholder.contract_version, generatedAt: placeholder.generated_at, currentArtifacts: manifestDigests(stagedManifest), - currentCoverage: (currentCoverage || {}) as unknown as CoverageSnapshot, - currentSubnets: { - subnets: (currentSubnets?.subnets as SubnetEntry[] | undefined) || [], - }, + currentCoverage: currentCoverage || {}, + currentSubnets: { subnets: subnetsOf(currentSubnets) }, previousArtifacts: manifestDigests(previousManifest), - previousCoverage: (previousCoverage || - null) as unknown as CoverageSnapshot | null, + previousCoverage: previousCoverage || null, previousSubnets: previousSubnets - ? { - subnets: (previousSubnets.subnets as SubnetEntry[] | undefined) || [], - } + ? { subnets: subnetsOf(previousSubnets) } : null, }); const summary = changelog.summary as Row; diff --git a/scripts/changelog.ts b/scripts/changelog.ts index 110ac8ce6c..982aa2fcd5 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -16,6 +16,12 @@ export interface ArtifactEntry { [key: string]: unknown; } +/** + * A subnet, as the diff identifies one. Unlike coverage, these fields are NOT + * ceremony: `netuid` is the Map key the entire diff turns on, and `name` is + * what a rename is detected from. See `subnetEntries` for why they are checked + * rather than declared. + */ export interface SubnetEntry { netuid: number; name: string; @@ -23,14 +29,55 @@ export interface SubnetEntry { [key: string]: unknown; } -export interface CoverageSnapshot { - candidate_count: number; - curated_overlay_count: number; - native_only_count: number; - surface_count: number; - [key: string]: unknown; +/** + * The identifiable subnets in a list, dropping the rest. + * + * Callers hold `Record` rows -- one from this build, one + * parsed from the previous publish's JSON -- and used to assert them into + * `SubnetEntry[]` wholesale. An entry missing `netuid` then keys + * `previousByNetuid` under `undefined`, so every other unidentifiable entry + * collides with it and the diff reports one arbitrary subnet renamed from and + * to whatever those rows happened to hold. + * + * Dropping rather than throwing is deliberate: the previous side of this diff + * is a document some earlier deploy wrote, and one unreadable historical row + * should cost that row, not the publish. + */ +function subnetEntries(rows: readonly Row[]): SubnetEntry[] { + const entries: SubnetEntry[] = []; + for (const row of rows) { + const { netuid, name, slug } = row; + if ( + typeof netuid === "number" && + typeof name === "string" && + typeof slug === "string" + ) { + entries.push({ ...row, netuid, name, slug }); + } + } + return entries; +} + +/** The `subnets` list out of a subnets artifact, or nothing. Extraction only -- + * `diffSubnets` does the identifying. */ +export function subnetsOf(artifact: Row | null | undefined): readonly Row[] { + const subnets = artifact?.subnets; + return Array.isArray(subnets) ? subnets : []; } +/** + * A coverage artifact, as loosely as this module actually reads one. + * + * This used to declare the four counts as required numbers, and every one of + * the four call sites had to assert its way past that -- including + * `(currentCoverage || {}) as unknown as CoverageSnapshot` in + * build-changelog.ts, which claimed four required numbers about `{}`. Nothing + * needed the declaration: `delta` below takes `unknown` and checks, which is + * the right thing to do with a document a PREVIOUS publish wrote. So the + * checking stays and the claim goes. + */ +export type CoverageSnapshot = Row; + export function buildChangelog({ contractVersion, currentArtifacts, @@ -44,11 +91,17 @@ export function buildChangelog({ contractVersion: unknown; currentArtifacts: ArtifactEntry[]; currentCoverage: CoverageSnapshot; - currentSubnets: { subnets?: SubnetEntry[] }; + // `subnets` is REQUIRED, and that is the whole reason this compiles honestly. + // With it optional the property is a "weak type" match, and TypeScript does + // not check a source index signature against an optional target property -- + // so a bare `Record` read straight from JSON satisfied this + // parameter with no cast and no complaint, and `subnets` was trusted as + // SubnetEntry[] the whole way down. Callers go through `subnetEntriesOf`. + currentSubnets: { subnets: readonly Row[] }; generatedAt: unknown; previousArtifacts?: ArtifactEntry[] | null; previousCoverage?: CoverageSnapshot | null; - previousSubnets?: { subnets?: SubnetEntry[] } | null; + previousSubnets?: { subnets: readonly Row[] } | null; }): Row { const previousArtifactList = previousArtifacts || []; const previousMap = new Map( @@ -71,7 +124,7 @@ export function buildChangelog({ // A null subnet baseline means "no previous publish to diff against" (the // build, pre-publish) → empty, NOT everything-added. const subnetChanges = previousSubnets - ? diffSubnets(previousSubnets.subnets || [], currentSubnets.subnets || []) + ? diffSubnets(previousSubnets.subnets, currentSubnets.subnets) : { added: [], removed: [], renamed: [] }; const coverageDelta = previousCoverage ? { @@ -123,9 +176,16 @@ export function buildChangelog({ } export function diffSubnets( - previousSubnets: SubnetEntry[], - currentSubnets: SubnetEntry[], + previousRows: readonly Row[], + currentRows: readonly Row[], ): { added: Row[]; removed: Row[]; renamed: Row[] } { + // Identified here rather than by the caller, so there is no version of this + // that skips the check. The parameters are deliberately `Row[]`: both sides + // are read from JSON some publish wrote, and a signature promising + // SubnetEntry[] only moved the assertion up one frame -- which is exactly + // where it used to live. + const previousSubnets = subnetEntries(previousRows); + const currentSubnets = subnetEntries(currentRows); const previousByNetuid = new Map( previousSubnets.map((subnet) => [subnet.netuid, subnet]), ); @@ -165,12 +225,10 @@ function delta( before: unknown, after: unknown, ): { before: number; after: number; delta: number } | null { - if (!Number.isFinite(before) || !Number.isFinite(after)) { - return null; - } - return { - before: before as number, - after: after as number, - delta: (after as number) - (before as number), - }; + // `typeof` first so the narrowing is real. Number.isFinite does not widen a + // guard into a type, which is why the three assertions below it existed -- + // and it accepts only actual numbers, so this rejects exactly what it did. + if (typeof before !== "number" || typeof after !== "number") return null; + if (!Number.isFinite(before) || !Number.isFinite(after)) return null; + return { before, after, delta: after - before }; } diff --git a/scripts/check-graphql-conformance.ts b/scripts/check-graphql-conformance.ts index 34fab3860f..3a8ba288e1 100644 --- a/scripts/check-graphql-conformance.ts +++ b/scripts/check-graphql-conformance.ts @@ -46,13 +46,18 @@ // #10246 was: same question, same second, two different totals. // // Scheduled out of band like its MCP sibling, never in CI: it needs production. -import { buildSchema } from "graphql"; +import { + buildSchema, + getNamedType, + isEnumType, + isObjectType, + isScalarType, +} from "graphql"; import type { GraphQLArgument, GraphQLField, + GraphQLFieldMap, GraphQLNamedType, - GraphQLObjectType, - GraphQLOutputType, GraphQLSchema, } from "graphql"; import { SDL } from "../generated/graphql/schema.ts"; @@ -168,24 +173,23 @@ export interface ConformanceReport { findings: Finding[]; } -function namedTypeOf(type: GraphQLOutputType): GraphQLNamedType { - let current = type as Row; - while (current.ofType) current = current.ofType; - return current as unknown as GraphQLNamedType; -} +// These three were hand-rolled against `constructor.name` and an `ofType` +// walk, which is graphql-js's own unwrapping reimplemented through `Row` and +// then asserted back. The library exports all of it, and its versions are +// type PREDICATES -- so the narrowing is real and the assertions go with it. +// Reading a class name also breaks silently under any bundler that mangles +// names, and this script is the one that runs against production. function isLeaf(type: GraphQLNamedType): boolean { - const kind = (type as Row).constructor?.name ?? ""; - return kind === "GraphQLScalarType" || kind === "GraphQLEnumType"; + return isScalarType(type) || isEnumType(type); } -function objectFieldsOf(type: GraphQLNamedType): Row | null { - const getFields = (type as Row).getFields; - if (typeof getFields !== "function") return null; - // Unions and interfaces need inline fragments to select through; the fields - // this sweep is after are all on plain object types. - if ((type as Row).constructor?.name !== "GraphQLObjectType") return null; - return (type as GraphQLObjectType).getFields() as unknown as Row; +/** Unions and interfaces need inline fragments to select through; the fields + * this sweep is after are all on plain object types. */ +function objectFieldsOf( + type: GraphQLNamedType, +): GraphQLFieldMap | null { + return isObjectType(type) ? type.getFields() : null; } /** @@ -201,12 +205,9 @@ function selectionFor(type: GraphQLNamedType, depth: number): string { if (!fields) return ""; const leaves: string[] = []; const branches: string[] = []; - for (const field of Object.values(fields) as GraphQLField< - unknown, - unknown - >[]) { + for (const field of Object.values(fields)) { if (field.args.some((arg) => String(arg.type).endsWith("!"))) continue; - const named = namedTypeOf(field.type); + const named = getNamedType(field.type); if (isLeaf(named)) { if (leaves.length < MAX_FIELDS_PER_LEVEL) leaves.push(field.name); continue; @@ -252,7 +253,7 @@ export function planFor( supplied.push(`${arg.name}: ${argumentLiteral(value)}`); } const args = supplied.length > 0 ? `(${supplied.join(", ")})` : ""; - const selection = selectionFor(namedTypeOf(field.type), 1); + const selection = selectionFor(getNamedType(field.type), 1); return { field: field.name, query: `{ ${field.name}${args} ${selection} }`, @@ -260,7 +261,7 @@ export function planFor( // surface's own complexity budget. That refusal is this sweep's query // being too big, not the field being broken, and reporting it as a // finding would be reporting our own probe. - narrowQuery: `{ ${field.name}${args} ${selectionFor(namedTypeOf(field.type), MAX_DEPTH)} }`, + narrowQuery: `{ ${field.name}${args} ${selectionFor(getNamedType(field.type), MAX_DEPTH)} }`, mirrors: mirroredRoute(field.description), }; } diff --git a/scripts/check-mcp-conformance.ts b/scripts/check-mcp-conformance.ts index ed903fbe33..f2556b0b6f 100644 --- a/scripts/check-mcp-conformance.ts +++ b/scripts/check-mcp-conformance.ts @@ -32,7 +32,6 @@ // flag never fails. "The response does not match the schema we publish" needs // no judgement to confirm, so it is an error rather than a flag. import { Ajv2020 } from "ajv/dist/2020.js"; -import addFormats from "ajv-formats"; // Live MCP JSON-RPC payloads, read for reporting. Same `Row` precedent as // scripts/mcp-smoke-sweep.ts and scripts/lib.ts: an unexpected shape is what @@ -46,6 +45,7 @@ import { projectableFieldFrom, projectionArgumentFor, } from "./mcp-tool-arguments.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; const ENDPOINT = process.env.MCP_CONFORMANCE_ENDPOINT || "https://api.metagraph.sh/mcp"; @@ -139,7 +139,7 @@ function ajv() { }); // Same cast as scripts/check-response-conformance.ts: ajv-formats' CJS // default export has no call signature under this module resolution. - (addFormats as unknown as (a: unknown) => void)(instance); + addAjvFormats(instance); return instance; } diff --git a/scripts/check-response-conformance.ts b/scripts/check-response-conformance.ts index 13188d5832..969fb4b4de 100644 --- a/scripts/check-response-conformance.ts +++ b/scripts/check-response-conformance.ts @@ -16,10 +16,10 @@ // build-time tests never see the difference. import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; -import Ajv2020 from "ajv/dist/2020.js"; -import addFormats from "ajv-formats"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { API_ROUTES } from "../src/contracts.ts"; import { apiRouteUrl } from "./smoke-live-api.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; const BASE = process.env.CONFORMANCE_API_BASE || "https://api.metagraph.sh"; const SPEC_PATH = @@ -47,21 +47,18 @@ export function buildValidator( spec: Record, routePath: string, ): Validator | null { - const ajv = new ( - Ajv2020 as unknown as new (o: unknown) => { - addSchema: (s: unknown, k: string) => void; - compile: (s: unknown) => { - (body: unknown): boolean; - errors?: { instancePath?: string; message?: string }[] | null; - }; - } - )( + // The NAMED export, like the five sibling validators use. The default + // import resolves to the module object rather than the constructor, which is + // why this had to restate Ajv's whole API as a hand-written `new (o: unknown) + // => { addSchema; compile }` and assert the class into it -- a shape that + // could not notice ajv changing under it (#11339). + const ajv = new Ajv2020( // allErrors: a route can violate its schema in more than one place, and // stopping at the first turns one bug into a queue of them -- /api/v1/rpc/pools // had two, and fixing #9138 is what "revealed" #9142. { strict: false, allErrors: true, validateFormats: false }, ); - (addFormats as unknown as (a: unknown) => void)(ajv); + addAjvFormats(ajv); ajv.addSchema(spec, "openapi.json"); const paths = spec.paths as Record>; diff --git a/scripts/lib.ts b/scripts/lib.ts index f59f28c495..945ad69806 100644 --- a/scripts/lib.ts +++ b/scripts/lib.ts @@ -25,7 +25,7 @@ import { existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { createHash, type BinaryLike } from "node:crypto"; import { lookup } from "node:dns/promises"; -import { isIP } from "node:net"; +import { isIP, type LookupFunction } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Agent } from "undici"; @@ -1638,18 +1638,17 @@ export function createPinnedLookup( hostname: string, address: string, family: number, -) { - return ( - requestedHostname: string, - options: { all?: boolean } | undefined, - callback: ( - err: Error | null, - address?: string | { address: string; family: number }[], - family?: number, - ) => void, - ): void => { +): LookupFunction { + return (requestedHostname, options, callback): void => { if (normalizeHostname(requestedHostname) !== hostname) { - callback(new Error("safeFetch attempted to resolve an unpinned host")); + // `[]` rather than a fabricated address. Every consumer checks the error + // argument first and never reads this one, and "resolved nothing" is the + // truthful thing to say -- Node's LookupFunction declares the parameter + // required, so the choice is between an empty result and a lie. + callback( + new Error("safeFetch attempted to resolve an unpinned host"), + [], + ); return; } if (options?.all) { @@ -1666,7 +1665,7 @@ function createPinnedAddressDispatcher( family: number, ): Agent { return new Agent({ - connect: { lookup: createPinnedLookup(hostname, address, family) as never }, + connect: { lookup: createPinnedLookup(hostname, address, family) }, }); } diff --git a/scripts/lib/ajv-formats.ts b/scripts/lib/ajv-formats.ts new file mode 100644 index 0000000000..fd5f8dcb14 --- /dev/null +++ b/scripts/lib/ajv-formats.ts @@ -0,0 +1,19 @@ +// `ajv-formats` through its real CJS/ESM interop shape (#11339). +// +// Under NodeNext, `import addFormats from "ajv-formats"` resolves to the +// package's `module.exports` OBJECT, not to the callable inside it. Six +// validators each wrote the same cast to get past that: +// +// const addFormats = addFormatsPlugin as unknown as (a: Ajv2020) => void; +// +// ...which is a claim about a third-party module's shape, made six times, none +// of them checked -- and each one also silenced any error in the argument at +// the call. `.default` IS the callable, and reaching for it type-checks on its +// own, so the interop is stated once here instead. +import addFormatsPlugin from "ajv-formats"; +import type { Ajv2020 } from "ajv/dist/2020.js"; + +/** Register ajv-formats' string formats on an Ajv instance. */ +export function addAjvFormats(ajv: Ajv2020): void { + addFormatsPlugin.default(ajv); +} diff --git a/scripts/lib/load-alpha-price-history.ts b/scripts/lib/load-alpha-price-history.ts index beb864c5f3..a0622d20a5 100644 --- a/scripts/lib/load-alpha-price-history.ts +++ b/scripts/lib/load-alpha-price-history.ts @@ -34,7 +34,12 @@ export const ALPHA_PRICE_HISTORY_LOOKBACK_DAYS = 40; /** The `pg` surface this needs, so a test can hand in a fake. */ export interface PgLike { - connect(): Promise; + /** `Promise`, because a real `pg.Client.connect()` resolves to the + * client and this module awaits it for the side effect. Declaring + * `Promise` did not make anything safer -- it made `pg.Client` fail + * to match, and the assertion that papered over it also stopped the + * compiler checking `query` and `end` at the same call. */ + connect(): Promise; end(): Promise; query(text: string): Promise<{ rows?: unknown[] } | undefined>; } @@ -98,7 +103,7 @@ export function alphaPriceHistoryQuery( export async function loadAlphaPriceHistoryByNetuid( env: AlphaPriceHistoryEnv = process.env, clientFactory: (connectionString: string) => PgLike = (connectionString) => - new pg.Client({ connectionString }) as unknown as PgLike, + new pg.Client({ connectionString }), ): Promise | null> { const connectionString = env.DATABASE_URL; // No connection string is the ordinary local/PR case: bake with null change diff --git a/scripts/lib/worker-env.ts b/scripts/lib/worker-env.ts new file mode 100644 index 0000000000..2f24863dfd --- /dev/null +++ b/scripts/lib/worker-env.ts @@ -0,0 +1,56 @@ +// Typed partial envs for the per-Worker entrypoints (#11339). +// +// Lives under scripts/lib so BOTH the suites and the validator scripts can +// import it: `scripts/validate-api.ts` and friends drive the same Worker +// handlers a test does, and were building the same fake env with the same +// `as unknown as Env` -- 24 of them, each free to name a different type. +// +// Each Worker's env is now its OWN generated bindings plus the concerns +// workers/env-extra.d.ts assigns it, rather than the single merged `Env` every +// generated file used to declare. That is the point: referencing a binding this +// Worker does not have is a type error now, not a runtime `undefined` (#10186). +// +// It also means a suite can no longer hand a handler `{} as unknown as Env` -- +// `Env` is the MAIN Worker's env, and passing it to a data-api handler is +// exactly the confusion the split exists to catch. +// +// ONE CAST, HERE. A test fixture cannot satisfy a real `DataApiEnv` -- it +// declares live platform bindings (a KVNamespace, a Hyperdrive, a Queue, a +// Durable Object namespace) that only the runtime can construct, and a suite +// supplies stubs for the two or three its route actually touches. So the cast +// is real; what was wrong was having it at 55 call sites, each free to name a +// different type. +// +// The parameter is keyed on the real env but valued `unknown`: a binding NAME +// that this Worker does not have is still a type error -- which is the half +// that catches the #10186 class -- while a hand-rolled stub standing in for a +// live binding is allowed, which is the half a suite needs. `Partial` +// would fail that second half, since it keeps each present key's full platform +// type. +import type { + ApiWorkerEnv, + DataApiWorkerEnv, + RegistrySyncWorkerEnv, +} from "../../workers/types.ts"; + +/** Every binding this Worker has, each optional and each free to be a stub. */ +export type EnvStub = { [K in keyof T]?: unknown }; + +/** A partial `DataApiEnv` for a suite driving data-api's handlers. */ +export function dataApiEnv( + overrides: EnvStub = {}, +): DataApiWorkerEnv { + return overrides as DataApiWorkerEnv; +} + +/** A partial `RegistrySyncApiEnv`. */ +export function registrySyncEnv( + overrides: EnvStub = {}, +): RegistrySyncWorkerEnv { + return overrides as RegistrySyncWorkerEnv; +} + +/** A partial `Env` -- the MAIN API Worker's, not any other's. */ +export function apiEnv(overrides: EnvStub = {}): ApiWorkerEnv { + return overrides as ApiWorkerEnv; +} diff --git a/scripts/probes-smoke.ts b/scripts/probes-smoke.ts index c3ea6a7263..08238bde82 100644 --- a/scripts/probes-smoke.ts +++ b/scripts/probes-smoke.ts @@ -19,6 +19,7 @@ import { import { mapLimit, nodeWebSocketConnector, + isProbeSurface, probeSurface as coreProbeSurface, rollupSubnetStatus, type ProbeSurface, @@ -31,10 +32,27 @@ const contractVersion = CONTRACT_VERSION; const subnets: Row[] = await loadSubnets(); const providers: Row[] = await loadProviders(); const allSurfaces: Row[] = flattenSurfaces(subnets); -const surfaces = allSurfaces.filter( +const enabledSurfaces = allSurfaces.filter( (surface) => (surface.probe as Row | undefined)?.enabled && surface.public_safe, ); +// Checked, not asserted. validate:surface already requires `url` and `kind` on +// every surface, so a row failing here means something upstream broke -- and +// the failure mode without the check is worse than a crash: an undefined url +// probes as a failure, so the surface would be reported DOWN rather than +// unprobeable, and its subnet's health would fall for a reason no one could +// see in the artifact. +const unprobeable = enabledSurfaces.filter( + (surface) => !isProbeSurface(surface), +); +if (unprobeable.length > 0) { + throw new Error( + `${unprobeable.length} probe-enabled surface(s) are missing a string ` + + `kind or url and cannot be probed: ` + + `${unprobeable.map((surface) => String(surface.id)).join(", ")}`, + ); +} +const surfaces = enabledSurfaces.filter(isProbeSurface); const startedAt = Date.now(); const priorHistory = await loadPriorHistory(); @@ -84,11 +102,8 @@ const probeOptions = { connect: nodeWebSocketConnector(), }; -async function probeSurface(surface: Row): Promise { - const base = await coreProbeSurface( - surface as unknown as ProbeSurface, - probeOptions, - ); +async function probeSurface(surface: Row & ProbeSurface): Promise { + const base = await coreProbeSurface(surface, probeOptions); const history: Row[] = priorHistory.get(surface.id as string) || []; const lastOk = base.status === "ok" diff --git a/scripts/refresh-og-image.ts b/scripts/refresh-og-image.ts index 973772bdbd..56e0a18803 100644 --- a/scripts/refresh-og-image.ts +++ b/scripts/refresh-og-image.ts @@ -32,6 +32,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { Resvg } from "@resvg/resvg-js"; +import type { ReactNode } from "react"; import satori from "satori"; import { html } from "satori-html"; import { R2_STAGING_RELATIVE_ROOT } from "../src/artifact-storage.ts"; @@ -103,7 +104,14 @@ async function renderCard(statParts: string[] | null): Promise { loadGoogleFont("Space Grotesk", 700), loadGoogleFont("Space Grotesk", 500), ]); - const svg = await satori(html(renderMarkup(statParts)) as never, { + // satori-html returns satori's own `VNode`; satori's published signature + // says `ReactNode` because React is its reference renderer. Both describe + // the same runtime object -- satori walks `{ type, props }` and never + // touches a React internal -- but neither package declares the other, so the + // relationship is stated once, here. `as ReactNode` and not `as never`: the + // latter accepts every value there is, including the `undefined` that a + // renderMarkup returning nothing would hand over. + const svg = await satori(html(renderMarkup(statParts)) as ReactNode, { width: CARD_WIDTH, height: CARD_HEIGHT, fonts: [ diff --git a/scripts/review-treasury-readings.ts b/scripts/review-treasury-readings.ts index 962495541b..aaf4407569 100644 --- a/scripts/review-treasury-readings.ts +++ b/scripts/review-treasury-readings.ts @@ -34,7 +34,10 @@ // produce the same candidate forever with nothing recording that a human had // already dismissed it. import pg from "pg"; -import { TREASURY_REVIEW_STATES } from "../schemas-src/treasury.ts"; +import { + PromotableTreasuryReviewStateSchema, + type PromotableTreasuryReviewState, +} from "../schemas-src/treasury.ts"; interface CandidateRow { netuid: number; @@ -49,18 +52,15 @@ interface CandidateRow { review_state: string; } -/** The states a maintainer may promote INTO. `candidate` is what the extractor - * writes; promoting something back to it would be undoing a review rather than - * making one, and there is no reason to do that from here. */ -export const PROMOTABLE_STATES = TREASURY_REVIEW_STATES.filter( - (state) => state !== "candidate", -); +/** The states a maintainer may promote INTO, for the usage message. The set + * itself lives with the vocabulary in schemas-src/treasury.ts. */ +export const PROMOTABLE_STATES = PromotableTreasuryReviewStateSchema.options; export interface ReviewCommand { action: "list" | "promote"; netuid?: number; sourceUrl?: string; - state?: string; + state?: PromotableTreasuryReviewState; } /** @@ -102,12 +102,19 @@ export function parseReviewArgs( "promote whichever the database returned first.", }; } - if (!state || !PROMOTABLE_STATES.includes(state as never)) { + // Parsed rather than tested, so what survives is the narrow union. The + // value goes straight into `SET review_state = $3`, and it is also compared + // against a literal further down -- with a bare `string` a typo in either + // place compiles and reaches the database. + const promotable = PromotableTreasuryReviewStateSchema.safeParse(state); + if (!promotable.success) { return { error: `promote needs a state, one of: ${PROMOTABLE_STATES.join(", ")}`, }; } - return { command: { action: "promote", netuid, sourceUrl, state } }; + return { + command: { action: "promote", netuid, sourceUrl, state: promotable.data }, + }; } /** One candidate, printed with the fields the served card withholds. */ diff --git a/scripts/validate-ai-routes.ts b/scripts/validate-ai-routes.ts index 7369c8dbd7..52d6ccca64 100644 --- a/scripts/validate-ai-routes.ts +++ b/scripts/validate-ai-routes.ts @@ -9,16 +9,16 @@ import assert from "node:assert/strict"; import path from "node:path"; import { Ajv2020 } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import { handleRequest } from "../workers/api.ts"; import { EMBED_MODEL } from "../src/ai-search.ts"; import { createLocalArtifactEnv, readJson, repoRoot } from "./lib.ts"; +import { apiEnv } from "./lib/worker-env.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // ajv-formats' default export resolves to the CJS module namespace rather than // the plugin function under this project's NodeNext + esModuleInterop // resolution -- cast to its real callable signature rather than fight the // interop. Mirrors validate-openapi-examples.ts. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; // Live handler responses/env stubs are read/built dynamically for assertion // purposes only, never trusted for control flow. Mirrors the @@ -27,7 +27,7 @@ const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; type Row = Record; const ajv = new Ajv2020({ allErrors: true, strict: false }); -addFormats(ajv); +addAjvFormats(ajv); const semanticSchema = ajv.compile( await readJson(path.join(repoRoot, "schemas/ai/semantic-search.schema.json")), ); @@ -39,7 +39,7 @@ const SEMANTIC_URL = "https://api.metagraph.sh/api/v1/search/semantic"; const ASK_URL = "https://api.metagraph.sh/api/v1/ask"; function get(url: string, env: Row) { - return handleRequest(new Request(url), env as unknown as Env, {}); + return handleRequest(new Request(url), apiEnv(env), {}); } function post( url: string, @@ -53,7 +53,7 @@ function post( headers: { "content-type": "application/json", ...headers }, body: typeof body === "string" ? body : JSON.stringify(body), }), - env as unknown as Env, + apiEnv(env), {}, ); } diff --git a/scripts/validate-api.ts b/scripts/validate-api.ts index 69cbcdd5ed..028baa9e14 100644 --- a/scripts/validate-api.ts +++ b/scripts/validate-api.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import path from "node:path"; import { API_ROUTES, @@ -22,6 +21,8 @@ import { buildAccountIdentity } from "../src/account-identity.ts"; import { blockEmissionForIssuance } from "../src/block-emission.ts"; import { taoToRao } from "../src/emission-decomposition.ts"; import {} from "../workers/request-params.ts"; +import { apiEnv } from "./lib/worker-env.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // OpenAPI document + Worker response bodies are dynamic JSON read only for // assertion purposes -- never trusted for control flow. Mirrors the @@ -116,10 +117,7 @@ const ajv = new Ajv2020({ strict: false, validateFormats: true, }); -// ajv-formats has no named export to sidestep the NodeNext/esModuleInterop -// resolution -- cast to its real callable signature. Mirrors validate-schemas.ts. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; -addFormats(ajv); +addAjvFormats(ajv); const fixtureDetail = { schema_version: 1, @@ -3027,7 +3025,7 @@ for (const [route, assertion, options = {}] of checks) { body: JSON.stringify(options.body), }, ), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal( @@ -3231,7 +3229,7 @@ for (const [route, assertion, options = {}] of checks) { }); const response = await handleRequest( new Request(`https://metagraph.sh${route}`), - forwardingEnv as unknown as Env, + apiEnv(forwardingEnv), {}, ); assert.equal(response.status, 200, `${flag} ${route}: expected 200`); @@ -3313,7 +3311,7 @@ for (const [route, assertion, options = {}] of checks) { }); await handleRequest( new Request(`https://metagraph.sh${route}`), - sweptEnv as unknown as Env, + apiEnv(sweptEnv), {}, ); assert.equal( @@ -3335,7 +3333,7 @@ const paginated = await handleRequest( new Request( "https://metagraph.sh/api/v1/subnets?limit=2&sort=netuid&order=desc", ), - env as unknown as Env, + apiEnv(env), {}, ); const paginatedBody = (await paginated.json()) as Row; @@ -3358,7 +3356,7 @@ for (const route of [ ]) { const response = await handleRequest( new Request(`https://metagraph.sh${route}`), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal(response.status, 400, `${route}: expected invalid query`); @@ -3371,7 +3369,7 @@ for (const route of [ const etagSource = await handleRequest( new Request("https://metagraph.sh/api/v1/subnets/7"), - env as unknown as Env, + apiEnv(env), {}, ); const cached = await handleRequest( @@ -3380,14 +3378,14 @@ const cached = await handleRequest( "if-none-match": etagSource.headers.get("etag")!, }, }), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal(cached.status, 304, "matching ETag should return 304"); const missing = await handleRequest( new Request("https://metagraph.sh/api/v1/subnets/9999"), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal(missing.status, 404, "missing subnet should return 404"); @@ -3399,7 +3397,7 @@ assert.equal( const proxy = await handleRequest( new Request("https://metagraph.sh/rpc/v1/finney", { method: "POST" }), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal(proxy.status, 501, "RPC proxy should be disabled by default"); @@ -3414,10 +3412,10 @@ const blockedRpc = await handleRequest( params: [], }), }), - { + apiEnv({ ...env, METAGRAPH_ENABLE_RPC_PROXY: "true", - } as unknown as Env, + }), {}, ); assert.equal( @@ -3434,7 +3432,7 @@ const verifyMissing = await handleRequest( new Request( "https://metagraph.sh/api/v1/surfaces/zzz-not-a-real-surface/verify", ), - env as unknown as Env, + apiEnv(env), {}, ); assert.equal( @@ -3450,7 +3448,7 @@ assert.equal( const r2Fallback = await handleRequest( new Request("https://metagraph.sh/api/v1/changelog"), - { + apiEnv({ ASSETS: { async fetch() { return new Response("not found", { status: 404 }); @@ -3477,7 +3475,7 @@ const r2Fallback = await handleRequest( }; }, }, - } as unknown as Env, + }), {}, ); assert.equal( diff --git a/scripts/validate-committed-seed.ts b/scripts/validate-committed-seed.ts index 782d026a19..8c41fdae79 100644 --- a/scripts/validate-committed-seed.ts +++ b/scripts/validate-committed-seed.ts @@ -23,7 +23,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Ajv2020 } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import { API_ROUTES } from "../src/contracts.ts"; import { handleRequest } from "../workers/api.ts"; import { @@ -31,12 +30,13 @@ import { artifactStorageTierForPath, } from "../src/artifact-storage.ts"; import { createLocalArtifactEnv, readJson, repoRoot } from "./lib.ts"; +import { apiEnv } from "./lib/worker-env.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // ajv-formats' default export resolves to the CJS module namespace rather than // the plugin function under this project's NodeNext + esModuleInterop // resolution -- cast to its real callable signature rather than fight the // interop. Mirrors validate-openapi-examples.ts. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; // The OpenAPI document + generated route table are read for schema validation // only, never trusted for control flow. Mirrors the readJson/readArtifactJson @@ -79,7 +79,7 @@ export async function runCommittedSeedGate({ strict: false, validateFormats: true, }); - addFormats(ajv); + addAjvFormats(ajv); const routes = committedSeedRoutes(); const errors: string[] = []; @@ -89,7 +89,7 @@ export async function runCommittedSeedGate({ try { response = await handleRequest( new Request(`https://metagraph.sh${route.path}`), - env as unknown as Env, + apiEnv(env), {}, ); } catch (error) { diff --git a/scripts/validate-double-assertions.ts b/scripts/validate-double-assertions.ts index ca6704901a..9d425416da 100644 --- a/scripts/validate-double-assertions.ts +++ b/scripts/validate-double-assertions.ts @@ -37,20 +37,56 @@ // // ## The ratchet // -// `MAX_DOUBLE_ASSERTIONS` may only fall. It is at zero because #11339 drove it -// there; a PR that adds one fails, and a PR that removes one without lowering -// the budget ALSO fails, so the number tracks reality rather than intent. -import { readFileSync, readdirSync, statSync } from "node:fs"; +// `BUDGETS` is per area (a path prefix) and each entry may only fall. A PR that +// adds one fails, and a PR that removes one without lowering the budget ALSO +// fails, so every number tracks reality rather than intent. Five of the six +// areas are at zero; apps/ui ratchets down toward it. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; import { repoRoot } from "./lib.ts"; -/** The budget, which may only fall. Raising it is not a fix. */ -export const MAX_DOUBLE_ASSERTIONS = 0; +/** + * The budget per area, which may only fall. + * + * `src`, `workers`, `schemas-src`, `packages` and `scripts` are at ZERO and + * stay there: #11339 and #11361 drove the first two, #11368 the rest. A + * regression in any of them is a plain failure rather than a number to + * negotiate. + * + * `apps/ui` is the RATCHET. It was invisible to this gate until #11368, + * because the file walk filtered on `.endsWith(".ts")` and every route and + * component in that workspace is `.tsx` -- so the area carrying the most + * assertions in the repo was the one area never counted. Most of what is left + * there is two third-party shapes: TanStack Router's typed search/params + * generics, and @polkadot/api's codecs on a runtime with no augmentation + * package. Those want per-library helpers rather than a sweep, so the count + * falls in batches and the ceiling falls with it. + * + * A ratchet and not an exemption, deliberately: `scripts` spent #11368 falling + * 54 -> 21 -> 15 -> 6 -> 0 exactly this way. A declared exemption list stops + * being read the moment it is longer than a screen (see + * validate-untyped-db-reads.ts's note) and then hides exactly what it names. + * A number cannot hide anything. + * + * `tests` is NOT scanned, and that is a judgement rather than an oversight: a + * unit process cannot construct a `KVNamespace`, `Hyperdrive`, + * `ExecutionContext` or `R2Bucket`, so there the assertion is the mechanism. + * The useful discipline for fixtures is to centralise it -- see + * scripts/lib/worker-env.ts, whose key-checked parameter is what caught four + * fixtures still setting a binding retired two releases ago. + */ +export const BUDGETS: Readonly> = { + src: 0, + workers: 0, + "schemas-src": 0, + packages: 0, + scripts: 0, + "apps/ui": 83, +}; -/** Directories this repo ships. Tests build fixtures for platform bindings a - * suite cannot construct, which is a different problem from this one. */ -const SCANNED_DIRS = ["src", "workers"] as const; +const SCANNED_DIRS = Object.keys(BUDGETS); export interface DoubleAssertion { file: string; @@ -95,32 +131,49 @@ export function findDoubleAssertions( return found; } -function walkTypeScript(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir)) { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - out.push(...walkTypeScript(full)); - } else if ( - entry.endsWith(".ts") && - !entry.endsWith(".test.ts") && - !entry.endsWith(".d.ts") - ) { - out.push(full); - } - } - return out; +/** A test, by filename or by the directory it lives in. Not scanned -- see the + * header. `.unit.ts` under a tests/ tree counts: apps/ui/tests/e2e names its + * harness files that way, and they are tests whatever they are called. */ +function isTestFile(file: string): boolean { + return ( + /\.(test|spec)\.tsx?$/.test(file) || + /(^|\/)(tests|__tests__|e2e)\//.test(file) + ); +} + +/** + * The TypeScript files git actually tracks under `dir`. + * + * `git ls-files` rather than a directory walk, for two reasons that bit when + * apps/ui was added. It carries `.tsx`, which the old walk silently skipped -- + * every route and component in the UI workspace was invisible to this gate + * because the filter said `.endsWith(".ts")`. And it respects .gitignore, so + * build output (apps/ui/.output, dist, .vinxi, .tanstack) is excluded because + * it is untracked, not because a hardcoded list happened to name it. A skip + * list is one more thing that rots quietly; git already knows the answer. + */ +function trackedTypeScript(dir: string): string[] { + const listed = execFileSync("git", ["ls-files", "-z", "--", dir], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 1024 * 1024 * 64, + }); + return listed + .split("\0") + .filter( + (file) => + /\.tsx?$/.test(file) && !file.endsWith(".d.ts") && !isTestFile(file), + ); } export function scanRepository(): DoubleAssertion[] { const found: DoubleAssertion[] = []; for (const dir of SCANNED_DIRS) { - const root = path.join(repoRoot, dir); - for (const file of walkTypeScript(root)) { + for (const file of trackedTypeScript(dir)) { found.push( ...findDoubleAssertions( - path.relative(repoRoot, file), - readFileSync(file, "utf8"), + file, + readFileSync(path.join(repoRoot, file), "utf8"), ), ); } @@ -128,33 +181,71 @@ export function scanRepository(): DoubleAssertion[] { return found; } +/** + * Which budget a finding counts against: the LONGEST configured key that is a + * path prefix of it. + * + * Longest and not first, because `apps/ui` is nested. A plain top-level split + * would file every UI finding under `apps`, and if `apps` and `apps/ui` were + * ever both configured the shorter one would silently absorb the longer one's + * budget. + */ +export function areaOf(file: string, areas: readonly string[]): string { + let best = ""; + for (const area of areas) { + if ( + (file === area || file.startsWith(`${area}/`)) && + area.length > best.length + ) { + best = area; + } + } + return best || (file.split("/")[0] ?? file); +} + function main(): void { const found = scanRepository(); + const areas = Object.keys(BUDGETS); + const counts = new Map(areas.map((area) => [area, 0])); for (const cast of found) { - console.error(`${cast.file}:${cast.line} [${cast.kind}] ${cast.text}`); + const area = areaOf(cast.file, areas); + counts.set(area, (counts.get(area) ?? 0) + 1); } - if (found.length > MAX_DOUBLE_ASSERTIONS) { - console.error( - `\nvalidate:double-assertions FAILED: ${found.length} double ` + - `assertion(s), budget ${MAX_DOUBLE_ASSERTIONS}.\n` + - "Routing through `unknown` (or `never`) erases every relationship the " + - "compiler could have checked. Fix the TYPE that made it necessary: " + - "widen an over-strict parameter, narrow with a real guard, or parse " + - "the value against a schema in schemas-src/.", - ); - process.exit(1); + const errors: string[] = []; + for (const [area, budget] of Object.entries(BUDGETS)) { + const n = counts.get(area) ?? 0; + if (n > budget) { + for (const cast of found.filter((c) => areaOf(c.file, areas) === area)) { + console.error( + `${cast.file}:${cast.line} [${cast.kind}] ${cast.text}`, + ); + } + errors.push( + `${area}: ${n} double assertion(s), budget ${budget}. Routing through ` + + "`unknown` (or `never`) erases every relationship the compiler could " + + "have checked. Fix the TYPE that made it necessary: widen an " + + "over-strict parameter, narrow with a real guard, or parse the value " + + "against a schema in schemas-src/.", + ); + } else if (n < budget) { + // The other half of the ratchet. A budget above the real count is a + // budget nobody is holding, and the next addition slides in under it. + errors.push( + `${area}: ${n} remain but the budget is ${budget}. Lower BUDGETS["${area}"] ` + + `to ${n} in the same change that removed them.`, + ); + } } - if (found.length < MAX_DOUBLE_ASSERTIONS) { + if (errors.length) { console.error( - `\nvalidate:double-assertions FAILED: ${found.length} remain but the ` + - `budget is ${MAX_DOUBLE_ASSERTIONS}. Lower MAX_DOUBLE_ASSERTIONS to ` + - `${found.length} in the same change that removed them.`, + `\nvalidate:double-assertions FAILED:\n${errors.map((e) => ` - ${e}`).join("\n")}`, ); process.exit(1); } console.log( - `validate:double-assertions OK — ${found.length} double assertion(s) ` + - `(budget ${MAX_DOUBLE_ASSERTIONS}).`, + `validate:double-assertions OK — ${Object.entries(BUDGETS) + .map(([a, b]) => `${a} ${counts.get(a) ?? 0}/${b}`) + .join(", ")}.`, ); } diff --git a/scripts/validate-mcp.ts b/scripts/validate-mcp.ts index 6fe9752c1a..8e6a9faa94 100644 --- a/scripts/validate-mcp.ts +++ b/scripts/validate-mcp.ts @@ -50,6 +50,11 @@ import { latestArtifactDate, repoRoot, } from "./lib.ts"; +import { apiEnv } from "./lib/worker-env.ts"; +import type { + ChainFirehoseHubState, + McpSessionHubState, +} from "../workers/do-state.ts"; // MCP tool call results are dynamic JSON-RPC payloads, read only for // assertion purposes -- never trusted for control flow. Mirrors the @@ -177,14 +182,14 @@ async function mcpRaw( headers: { "content-type": "application/json", ...headers }, body: method === "POST" ? JSON.stringify(payload) : undefined, }); - return handleRequest(request, envOverride as unknown as Env, {}); + return handleRequest(request, apiEnv(envOverride), {}); } async function getJson(path: string): Promise { const request = new Request(`https://api.metagraph.sh${path}`, { method: "GET", }); - const response = await handleRequest(request, env as unknown as Env, {}); + const response = await handleRequest(request, apiEnv(env), {}); const text = await response.text(); return { status: response.status, body: text ? JSON.parse(text) : null }; } @@ -244,7 +249,15 @@ async function callOk(name: string, args: unknown): Promise { // updated round trip below exercises the actual class code, not a // hand-rolled simulation of it. -function inMemoryDoStorage() { +// The hub state doubles. Typed against the surfaces the hubs declare in +// workers/do-state.ts rather than asserted into `DurableObjectState`, so a hub +// that starts using something new fails to COMPILE here. That matters more +// than it sounds: every one of these hubs parks telemetry on +// `state.waitUntil` inside a `try` that swallows the error, so an absent +// member does not fail the run -- it silently removes the behaviour and this +// script still prints OK. + +function keyedDoStorage(): McpSessionHubState["storage"] { const data = new Map(); return { async get(keys: string[]) { @@ -263,6 +276,33 @@ function inMemoryDoStorage() { }; } +function singleKeyDoStorage(): ChainFirehoseHubState["storage"] { + const data = new Map(); + let alarm: number | null = null; + return { + async get(key: string) { + return data.get(key); + }, + async put(key: string, value: unknown) { + data.set(key, value); + }, + async getAlarm() { + return alarm; + }, + async setAlarm(scheduledTime: number | Date) { + alarm = + scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; + }, + }; +} + +// Runs the promise detached, which is what the real `waitUntil` guarantees and +// what the hubs' telemetry needs. Swallowing it here would make this script +// agree with the bug it exists to catch. +const detachedWaitUntil = (promise: Promise): void => { + void promise.catch(() => false); +}; + interface DoStub { fetch(request: Request): Promise | Response; } @@ -294,25 +334,35 @@ function fakeDoNamespace(makeInstance: (id: string) => DoStub) { const mcpSessionHubNS: ReturnType = fakeDoNamespace( () => new McpSessionHub( - { storage: inMemoryDoStorage() } as unknown as DurableObjectState, - { + { storage: keyedDoStorage(), waitUntil: detachedWaitUntil }, + apiEnv({ CHAIN_FIREHOSE_HUB: chainFirehoseHubNS, SUBNET_STATUS_HUB: subnetStatusHubNS, - } as unknown as Env, + }), ), ); const chainFirehoseHubNS: ReturnType = fakeDoNamespace( () => new ChainFirehoseHub( - { getWebSockets: () => [] } as unknown as DurableObjectState, - { MCP_SESSION_HUB: mcpSessionHubNS } as unknown as Env, + { + storage: singleKeyDoStorage(), + getWebSockets: () => [], + acceptWebSocket: () => { + // No hibernation in-process: this script drives the hub over its + // fetch surface and never opens a socket. Present because the hub + // calls it on the upgrade path, and an absent member is exactly the + // hole the old assertion left. + }, + waitUntil: detachedWaitUntil, + }, + apiEnv({ MCP_SESSION_HUB: mcpSessionHubNS }), ), ); const subnetStatusHubNS: ReturnType = fakeDoNamespace( () => new SubnetStatusHub( - { storage: inMemoryDoStorage() } as unknown as DurableObjectState, - { MCP_SESSION_HUB: mcpSessionHubNS } as unknown as Env, + { storage: keyedDoStorage(), waitUntil: detachedWaitUntil }, + apiEnv({ MCP_SESSION_HUB: mcpSessionHubNS }), ), ); const lifecycleEnv = createLocalArtifactEnv({ diff --git a/scripts/validate-openapi-examples.ts b/scripts/validate-openapi-examples.ts index 5ba2ebeb72..f6fa8edba5 100644 --- a/scripts/validate-openapi-examples.ts +++ b/scripts/validate-openapi-examples.ts @@ -6,16 +6,15 @@ // stay present + schema-correct, and surfaces any schema construct the sampler // mishandles. import { Ajv2020, type Schema, type ValidateFunction } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import path from "node:path"; import { API_ROUTES } from "../src/contracts.ts"; import { readJson, repoRoot } from "./lib.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // ajv-formats' default export resolves to the CJS module namespace rather than // the plugin function under this project's NodeNext + esModuleInterop // resolution (no named-export alternative exists, unlike Ajv2020 above) -- // cast to its real callable signature rather than fight the interop. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; const openapi = await readJson( path.join(repoRoot, "public/metagraph/openapi.json"), @@ -26,7 +25,7 @@ const ajv = new Ajv2020({ strict: false, validateFormats: true, }); -addFormats(ajv); +addAjvFormats(ajv); // Register the OpenAPI components block ONCE under an absolute id (mirroring // validate-schemas.ts), instead of re-inlining all ~198 schemas into every diff --git a/scripts/validate-schema-vocabularies.ts b/scripts/validate-schema-vocabularies.ts index 8e463d76eb..a1ba79ab0f 100644 --- a/scripts/validate-schema-vocabularies.ts +++ b/scripts/validate-schema-vocabularies.ts @@ -302,15 +302,44 @@ const [{ API_QUERY_COLLECTIONS }, mcpShared] = await Promise.all([ import("../src/contracts.ts"), import("../schemas-src/mcp-tools/shared.ts"), ]); -const collections = API_QUERY_COLLECTIONS as Record< - string, - { sort_fields?: readonly string[] } ->; -const mirrors = mcpShared as unknown as Record; +// A module namespace assigns to `Record` on its own -- what +// it does NOT do is assign to `Record`, which is +// what the old assertion claimed about every export in both modules. That +// claim is also the thing this script exists to doubt: it reads these to find +// out whether an export is still a vocabulary, having asserted that it is. +const collections: Record = API_QUERY_COLLECTIONS; +const mirrors: Record = mcpShared; + +/** + * A vocabulary, checked rather than assumed. + * + * `Array.isArray` alone was the whole check, and it leaves the elements `any`: + * an enum that had picked up a number, or a nested array, would be sorted and + * joined into the comparison string without complaint, and the drift report + * would print it as though it were a name. Both sides of every comparison + * below now come through here, so a malformed vocabulary is reported as + * malformed instead of being compared. + */ +function stringVocabulary(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") return null; + out.push(entry); + } + return out; +} + +/** `sort_fields` off one API_QUERY_COLLECTIONS entry, or null if the entry or + * the field is gone. */ +function sortFieldsOf(collection: unknown): readonly string[] | null { + if (typeof collection !== "object" || collection === null) return null; + return stringVocabulary((collection as Record).sort_fields); +} for (const [name, collection] of Object.entries(MIRRORED_VOCABULARIES)) { - const mirrored = mirrors[name]; - if (!Array.isArray(mirrored)) { + const mirrored = stringVocabulary(mirrors[name]); + if (mirrored === null) { errors.push( `${name} is declared as a mirror of API_QUERY_COLLECTIONS["${collection}"].sort_fields ` + `but schemas-src/mcp-tools/shared.ts no longer exports it — delete the entry above, ` + @@ -318,8 +347,8 @@ for (const [name, collection] of Object.entries(MIRRORED_VOCABULARIES)) { ); continue; } - const source = collections[collection]?.sort_fields; - if (!Array.isArray(source)) { + const source = sortFieldsOf(collections[collection]); + if (source === null) { errors.push( `${name} mirrors API_QUERY_COLLECTIONS["${collection}"], which no longer declares sort_fields.`, ); @@ -399,13 +428,16 @@ const JSON_SCHEMA_MIRRORS: Array<{ * validate:unreferenced-exports counted 898 against its 880 ceiling (#10292). * The table above stays the declaration; this is only how it is loaded. */ -const MIRROR_MODULE_LOADERS: Record Promise> = { +const MIRROR_MODULE_LOADERS: Record< + string, + () => Promise> +> = { "../schemas-src/shared.ts": () => import("../schemas-src/shared.ts"), "../schemas-src/routes/subnet-detail.ts": () => import("../schemas-src/routes/subnet-detail.ts"), }; -const mirrorModules = new Map>(); +const mirrorModules = new Map>(); for (const { module } of JSON_SCHEMA_MIRRORS) { if (mirrorModules.has(module)) continue; const load = MIRROR_MODULE_LOADERS[module]; @@ -417,10 +449,7 @@ for (const { module } of JSON_SCHEMA_MIRRORS) { `MIRROR_MODULE_LOADERS -- add one, with a literal import specifier.`, ); } - mirrorModules.set( - module, - (await load()) as unknown as Record, - ); + mirrorModules.set(module, await load()); } for (const { schemaFile, pointer, module, zodExport } of JSON_SCHEMA_MIRRORS) { @@ -432,22 +461,26 @@ for (const { schemaFile, pointer, module, zodExport } of JSON_SCHEMA_MIRRORS) { for (const key of pointer) { node = (node as Record | null)?.[key]; } - const zodValues = schemaSrcShared[zodExport]; - if (!Array.isArray(node)) { + const zodValues = stringVocabulary(schemaSrcShared[zodExport]); + const schemaValues = stringVocabulary(node); + if (schemaValues === null) { errors.push( - `${schemaFile} no longer has an enum at ${pointer.join(".")} — ` + - `update JSON_SCHEMA_MIRRORS, or restore the enum.`, + `${schemaFile} has no list-of-strings enum at ${pointer.join(".")} — ` + + `it is either gone (update JSON_SCHEMA_MIRRORS, or restore it) or it ` + + `has picked up a non-string entry, which is not a vocabulary and ` + + `cannot be compared against one.`, ); continue; } - if (!Array.isArray(zodValues)) { + if (zodValues === null) { errors.push( - `schemas-src/shared.ts no longer exports ${zodExport}, which mirrors ` + - `${schemaFile}#${pointer.join(".")}.`, + `${zodExport} is not a list of strings, but it mirrors ` + + `${schemaFile}#${pointer.join(".")} — it is either no longer exported ` + + `from ${module}, or no longer a vocabulary.`, ); continue; } - const fromSchema = [...(node as string[])].sort().join(","); + const fromSchema = [...schemaValues].sort().join(","); const fromZod = [...zodValues].sort().join(","); if (fromSchema !== fromZod) { errors.push( diff --git a/scripts/validate-schemas.ts b/scripts/validate-schemas.ts index d316df1363..20f364dd03 100644 --- a/scripts/validate-schemas.ts +++ b/scripts/validate-schemas.ts @@ -1,5 +1,4 @@ import { Ajv2020, type ErrorObject } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import { existsSync } from "node:fs"; import path from "node:path"; import { PUBLIC_ARTIFACTS, isComputedArtifact } from "../src/contracts.ts"; @@ -19,12 +18,12 @@ import { } from "../src/artifact-storage.ts"; import { DEFAULT_SS58_PREFIX, decodeSs58 } from "../src/ss58.ts"; import { createComponentValidatorCompiler } from "./lib/component-validator.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // ajv-formats' default export resolves to the CJS module namespace rather than // the plugin function under this project's NodeNext + esModuleInterop // resolution -- cast to its real callable signature rather than fight the // interop. Mirrors validate-openapi-examples.ts. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; // Schemas + registry/artifact documents are read for validation only, never // trusted for control flow. Mirrors the readJson/readArtifactJson precedent @@ -38,7 +37,7 @@ const ajv = new Ajv2020({ strict: false, validateFormats: true, }); -addFormats(ajv); +addAjvFormats(ajv); const providerSchema = await readJson( path.join(repoRoot, "schemas/provider.schema.json"), diff --git a/scripts/validate-surface.ts b/scripts/validate-surface.ts index d8de9a390c..911cdf7b91 100644 --- a/scripts/validate-surface.ts +++ b/scripts/validate-surface.ts @@ -7,7 +7,6 @@ // npm run validate:surface -- registry/subnets/.json // npm run validate:surface # validates every subnet file import { Ajv2020, type ErrorObject } from "ajv/dist/2020.js"; -import addFormatsPlugin from "ajv-formats"; import path from "node:path"; import { classifyNativeName, @@ -18,12 +17,12 @@ import { repoRoot, } from "./lib.ts"; import { RPC_POOL_KIND_VALUES } from "../schemas-src/query-params.ts"; +import { addAjvFormats } from "./lib/ajv-formats.ts"; // ajv-formats' default export resolves to the CJS module namespace rather than // the plugin function under this project's NodeNext + esModuleInterop // resolution -- cast to its real callable signature rather than fight the // interop. Mirrors validate-openapi-examples.ts. -const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; // Subnet registry documents are read for schema/convention validation only, // never trusted for control flow. Mirrors the readJson/readArtifactJson @@ -32,7 +31,7 @@ const addFormats = addFormatsPlugin as unknown as (instance: Ajv2020) => void; type Row = Record; const ajv = new Ajv2020({ allErrors: true, strict: false }); -addFormats(ajv); +addAjvFormats(ajv); const schema = await readJson( path.join(repoRoot, "schemas/subnet-manifest.schema.json"), ); diff --git a/scripts/worker-test.ts b/scripts/worker-test.ts index 0630d5341a..a0bede59ec 100644 --- a/scripts/worker-test.ts +++ b/scripts/worker-test.ts @@ -2,13 +2,14 @@ import assert from "node:assert/strict"; import { CONTRACT_VERSION } from "../src/contracts.ts"; import { handleRequest } from "../workers/api.ts"; import { createLocalArtifactEnv } from "./lib.ts"; +import { apiEnv } from "./lib/worker-env.ts"; // Live handler responses are read for assertion purposes only, never trusted // for control flow. Mirrors the readJson/readArtifactJson precedent in lib.ts. // eslint-disable-next-line @typescript-eslint/no-explicit-any type Row = Record; -const env = createLocalArtifactEnv() as unknown as Env; +const env = apiEnv(createLocalArtifactEnv()); const head = await handleRequest( new Request("https://metagraph.sh/api/v1/subnets", { method: "HEAD" }), @@ -86,7 +87,7 @@ assert.equal(await cached.text(), "", "304 should not return a body"); const r2Fallback = await handleRequest( new Request("https://metagraph.sh/api/v1/changelog"), - { + apiEnv({ ASSETS: { async fetch() { return new Response("not found", { status: 404 }); @@ -113,7 +114,7 @@ const r2Fallback = await handleRequest( }; }, }, - } as unknown as Env, + }), {}, ); assert.equal( @@ -209,7 +210,7 @@ for (const unsafeUrl of [ params: [], }), }), - { + apiEnv({ ...env, METAGRAPH_ENABLE_RPC_PROXY: "true", ASSETS: { @@ -231,7 +232,7 @@ for (const unsafeUrl of [ }; }, }, - } as unknown as Env, + }), {}, ); assert.equal( @@ -294,7 +295,7 @@ try { }, ], }; - const proxyEnv = { + const proxyEnv = apiEnv({ ...env, METAGRAPH_ENABLE_RPC_PROXY: "true", ASSETS: { @@ -316,7 +317,7 @@ try { }; }, }, - } as unknown as Env; + }); const proxied = await handleRequest( new Request("https://metagraph.sh/rpc/v1/finney", { method: "POST", diff --git a/src/health-probe-core.ts b/src/health-probe-core.ts index 205efe0547..5351dfeaaf 100644 --- a/src/health-probe-core.ts +++ b/src/health-probe-core.ts @@ -333,6 +333,30 @@ export interface ProbeSurface { probe: ProbeSurfaceProbeConfig; } +/** + * Is this registry row actually probeable? + * + * `kind` and `url` are the two fields the probe cannot work without -- `kind` + * selects the RPC path over the HTTP one, `url` is what gets fetched -- and + * `probe` carries the method and timeout. Everything else on `ProbeSurface` is + * `unknown` and rides along. + * + * A predicate rather than an assertion because the callers hold + * `Record` read from the registry: asserting one in claims + * `url: string` about a row that may not have the key, and `fetch(undefined)` + * is not a probe result, it is a crash inside a bounded concurrency pool. + */ +export function isProbeSurface( + row: Record, +): row is Record & ProbeSurface { + return ( + typeof row.kind === "string" && + typeof row.url === "string" && + typeof row.probe === "object" && + row.probe !== null + ); +} + export function classifyProbe( probe: HttpProbeResult, surface: ProbeSurface, diff --git a/tests/changelog-diff-subnets.test.ts b/tests/changelog-diff-subnets.test.ts index b8fb7c7366..1f2b59021a 100644 --- a/tests/changelog-diff-subnets.test.ts +++ b/tests/changelog-diff-subnets.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, test } from "vitest"; -import { diffSubnets } from "../scripts/changelog.ts"; +import { diffSubnets, subnetsOf } from "../scripts/changelog.ts"; describe("diffSubnets", () => { test("classifies added and removed subnets by netuid", () => { @@ -51,3 +51,72 @@ describe("diffSubnets", () => { }); }); }); + +describe("diffSubnets identifies its own input", () => { + test("keeps identifiable rows whole, extra fields and all", () => { + assert.deepEqual( + diffSubnets([], [{ netuid: 1, name: "Apex", slug: "apex", extra: true }]), + { + added: [{ netuid: 1, name: "Apex", slug: "apex" }], + removed: [], + renamed: [], + }, + ); + }); + + test("DROPS rows with no netuid, which is what the diff keys on", () => { + // Two rows missing `netuid` would both key the Map under `undefined`, so + // the second overwrites the first and one arbitrary subnet stands in for + // both. Checked, they never reach the Map -- and no cast was needed to + // write this test, which is the point of narrowing inside diffSubnets + // rather than at its callers. + assert.deepEqual( + diffSubnets( + [], + [ + { name: "One", slug: "one" }, + { name: "Two", slug: "two" }, + ], + ), + { added: [], removed: [], renamed: [] }, + ); + }); + + test("drops a row whose netuid is a STRING, not just a missing one", () => { + // "7" and 7 are different Map keys, so a stringified netuid would report + // the same subnet as both added and removed on every publish. + assert.deepEqual( + diffSubnets( + [{ netuid: 7, name: "A", slug: "a" }], + [{ netuid: "7", name: "A", slug: "a" }], + ), + { + added: [], + removed: [{ netuid: 7, name: "A", slug: "a" }], + renamed: [], + }, + ); + }); + + test("drops a row missing name or slug", () => { + assert.deepEqual(diffSubnets([], [{ netuid: 1, slug: "a" }]), { + added: [], + removed: [], + renamed: [], + }); + assert.deepEqual(diffSubnets([], [{ netuid: 1, name: "A" }]), { + added: [], + removed: [], + renamed: [], + }); + }); + + test("subnetsOf reads the `subnets` list, or nothing at all", () => { + assert.deepEqual(subnetsOf({ subnets: [{ netuid: 1 }] }), [{ netuid: 1 }]); + assert.deepEqual(subnetsOf(null), []); + assert.deepEqual(subnetsOf(undefined), []); + // A previous publish whose artifact predates the key, or holds junk there. + assert.deepEqual(subnetsOf({}), []); + assert.deepEqual(subnetsOf({ subnets: "not-a-list" }), []); + }); +}); diff --git a/tests/head-poller.test.ts b/tests/head-poller.test.ts index f4a0c7de53..da9efde1be 100644 --- a/tests/head-poller.test.ts +++ b/tests/head-poller.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { test, vi } from "vitest"; import { pgMockEnv } from "./helpers/pg-mock.ts"; +import { apiEnv } from "../scripts/lib/worker-env.ts"; // The hub's only durable write is `mirrorBlocksHeadToNeon`, which reaches // Postgres through `new Client(...)` inside src/pg-sql.ts (#10179). A DO test @@ -327,6 +328,11 @@ function hubWith(env: Record, storage: Map) { let alarmAt: number | null = null; const state = { getWebSockets: () => [], + // The hub calls this on the WebSocket upgrade path. This fixture never + // upgrades, but an absent member is a TypeError the moment anything here + // touches that path -- and until the constructor was typed against + // ChainFirehoseHubState, the `as never` below meant nothing said so. + acceptWebSocket: () => {}, // A DurableObjectState IS the waitUntil handle -- createPgSql hands the // pooled connection back through it, so a stub without one turns every // mirror write into a TypeError swallowed as a lane verdict. @@ -338,7 +344,7 @@ function hubWith(env: Record, storage: Map) { setAlarm: async (t: number) => void (alarmAt = t), }, }; - const hub = new ChainFirehoseHub(state as never, env as never); + const hub = new ChainFirehoseHub(state, apiEnv(env)); return { hub, state, alarm: () => alarmAt }; } @@ -466,6 +472,62 @@ test("alarm: broadcasts and durably records each new block, advancing last_seen" assert.ok(alarm() !== null, "re-armed"); }); +// Every corrupt cursor wedges the lane the same way, and all three reach it +// through a different branch of the guard -- a string fails the `typeof`, NaN +// and Infinity pass it and fail `Number.isFinite`. Verified against the real +// function: heightsToEmit(v, 16) is [] for each of these. +const CORRUPT_CURSORS: readonly unknown[] = [ + "14", + Number.NaN, + Number.POSITIVE_INFINITY, +]; + +for (const corrupt of CORRUPT_CURSORS) { + test(`alarm: a persisted cursor of ${String(corrupt)} is treated as absent, not compared`, async () => { + // The bug this pins is silent and permanent, which is why it needs a test + // rather than a type. heightsToEmit does `lastSeen + 1`; for a string that + // CONCATENATES, so "14" + 1 is "141", the emit loop starts past the head and + // never runs. Nothing is emitted, so nothing is written back, so the bad + // cursor is never overwritten -- the lane goes quiet at every head, forever, + // with no error to alarm on. `storage.get()` named the type instead + // of checking it, so the compiler was satisfied and nothing else looked. + const storage = new Map(); + storage.set("head:last_seen", corrupt); + const { hub, alarm } = hubWith( + { + CHAIN_HEAD_POLL_ENABLED: "true", + CHAIN_HEAD_RPC_URL: "https://rpc.example", + ...pgMockEnv(), + }, + storage, + ); + const seen: unknown[] = []; + (hub as unknown as { broadcast: (p: unknown) => Promise }).broadcast = + async (p) => void seen.push(p); + const realFetch = globalThis.fetch; + globalThis.fetch = rpcFetch({ + chain_getHeader: () => ({ number: "0x10" }), + chain_getBlockHash: (params) => `0xhash${(params as number[])[0]}`, + chain_getBlock: () => ({ + block: { header: { parentHash: "0xp" }, extrinsics: [1] }, + }), + }); + try { + await hub.alarm(); + } finally { + globalThis.fetch = realFetch; + } + // Absent cursor semantics: resume live from the head, one block, not a + // catch-up burst from a coerced number. + assert.equal(seen.length, 1, "resumed from the head"); + assert.equal((seen[0] as { block_number: number }).block_number, 16); + // The assertion that matters. An unchanged cursor IS the wedged lane: it + // means nothing was emitted and nothing was written back. + assert.equal(storage.get("head:last_seen"), 16); + assert.ok(alarm() !== null, "re-armed"); + }); +} + test("alarm: an RPC failure is contained and the chain re-arms", async () => { const { hub, alarm } = hubWith( { diff --git a/tests/health-probe-core.test.ts b/tests/health-probe-core.test.ts index 93af89671f..1e03fa41a8 100644 --- a/tests/health-probe-core.test.ts +++ b/tests/health-probe-core.test.ts @@ -6,6 +6,7 @@ import { classifyRpcProbe, contentMismatch, FINNEY_GENESIS_HASH, + isProbeSurface, isUnsafePublicUrl, mapLimit, nodeWebSocketConnector, @@ -2101,3 +2102,43 @@ describe("withProbeDeadline (metagraphed#9769)", () => { assert.equal(cleared, true); }); }); + +describe("isProbeSurface", () => { + const probeable = { + id: "s1", + kind: "rest", + url: "https://example.test/health", + probe: { method: "GET" }, + }; + + test("accepts a row carrying the three fields the probe needs", () => { + assert.equal(isProbeSurface(probeable), true); + }); + + test("rejects a row with no url — the field the probe fetches", () => { + // The reason this is a check and not an assertion: `fetch(undefined)` + // inside a bounded concurrency pool is a crash, and a url that arrived as + // something other than a string probes as a FAILURE, so the surface would + // be published as down rather than as unprobeable. + const { url: _url, ...noUrl } = probeable; + assert.equal(isProbeSurface(noUrl), false); + assert.equal(isProbeSurface({ ...probeable, url: 42 }), false); + }); + + test("rejects a row whose kind is missing or not a string", () => { + // `kind` selects the RPC path over the HTTP one; the wrong branch is not a + // probe of the same surface. + const { kind: _kind, ...noKind } = probeable; + assert.equal(isProbeSurface(noKind), false); + assert.equal(isProbeSurface({ ...probeable, kind: ["rest"] }), false); + }); + + test("rejects a row whose probe config is missing, null, or not an object", () => { + // null is the case worth naming: `typeof null === "object"`, so a check + // that stopped at typeof would accept it and then read `.method` off it. + const { probe: _probe, ...noProbe } = probeable; + assert.equal(isProbeSurface(noProbe), false); + assert.equal(isProbeSurface({ ...probeable, probe: null }), false); + assert.equal(isProbeSurface({ ...probeable, probe: "GET" }), false); + }); +}); diff --git a/tests/helpers/worker-env.ts b/tests/helpers/worker-env.ts index 959d926443..549ab6752f 100644 --- a/tests/helpers/worker-env.ts +++ b/tests/helpers/worker-env.ts @@ -1,51 +1,8 @@ -// Typed partial envs for the per-Worker entrypoints (#11339). -// -// Each Worker's env is now its OWN generated bindings plus the concerns -// workers/env-extra.d.ts assigns it, rather than the single merged `Env` every -// generated file used to declare. That is the point: referencing a binding this -// Worker does not have is a type error now, not a runtime `undefined` (#10186). -// -// It also means a suite can no longer hand a handler `{} as unknown as Env` -- -// `Env` is the MAIN Worker's env, and passing it to a data-api handler is -// exactly the confusion the split exists to catch. -// -// ONE CAST, HERE. A test fixture cannot satisfy a real `DataApiEnv` -- it -// declares live platform bindings (a KVNamespace, a Hyperdrive, a Queue, a -// Durable Object namespace) that only the runtime can construct, and a suite -// supplies stubs for the two or three its route actually touches. So the cast -// is real; what was wrong was having it at 55 call sites, each free to name a -// different type. -// -// The parameter is keyed on the real env but valued `unknown`: a binding NAME -// that this Worker does not have is still a type error -- which is the half -// that catches the #10186 class -- while a hand-rolled stub standing in for a -// live binding is allowed, which is the half a suite needs. `Partial` -// would fail that second half, since it keeps each present key's full platform -// type. -import type { - ApiWorkerEnv, - DataApiWorkerEnv, - RegistrySyncWorkerEnv, -} from "../../workers/types.ts"; - -/** Every binding this Worker has, each optional and each free to be a stub. */ -type EnvStub = { [K in keyof T]?: unknown }; - -/** A partial `DataApiEnv` for a suite driving data-api's handlers. */ -export function dataApiEnv( - overrides: EnvStub = {}, -): DataApiWorkerEnv { - return overrides as DataApiWorkerEnv; -} - -/** A partial `RegistrySyncApiEnv`. */ -export function registrySyncEnv( - overrides: EnvStub = {}, -): RegistrySyncWorkerEnv { - return overrides as RegistrySyncWorkerEnv; -} - -/** A partial `Env` -- the MAIN API Worker's, not any other's. */ -export function apiEnv(overrides: EnvStub = {}): ApiWorkerEnv { - return overrides as ApiWorkerEnv; -} +// Re-exported from scripts/lib so the suites and the validator scripts share +// ONE definition -- both drive the same Worker handlers and both need the same +// partial env (#11339). The doc comment explaining the single cast lives there. +export { + apiEnv, + dataApiEnv, + registrySyncEnv, +} from "../../scripts/lib/worker-env.ts"; diff --git a/tests/safe-fetch.test.ts b/tests/safe-fetch.test.ts index 8496c83807..f917ed5393 100644 --- a/tests/safe-fetch.test.ts +++ b/tests/safe-fetch.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { afterEach, describe, test, vi } from "vitest"; import { createPinnedLookup, safeFetch } from "../scripts/lib.ts"; +import type { LookupAddress } from "node:dns"; import type { Row } from "./row-type.ts"; // IP-literal URLs so isUnsafeResolvedUrl never needs DNS: 1.1.1.1 / 8.8.8.8 are @@ -152,42 +153,51 @@ describe("safeFetch SSRF guard", () => { describe("createPinnedLookup", () => { const PINNED = "93.184.216.34"; + // The callback parameters are inferred from `LookupFunction`, so these + // assertions are checked against the contract Node and undici actually call + // this with. They used to be asserted into place, which meant the test + // agreed with itself: it declared `address?: string` where the contract says + // `string | LookupAddress[]`, and the `{ all: true }` case below would have + // type-checked just as happily against the single-answer shape. + type Capture = { + err: NodeJS.ErrnoException | null; + address: string | LookupAddress[]; + family?: number; + }; + test("resolves the pinned host to the vetted address (single + all forms)", () => { const lookup = createPinnedLookup("example.test", PINNED, 4); // Node's single-answer form: callback(err, address, family). - let single: Row | undefined; - lookup("example.test", {}, (( - err: Error | null, - address?: string, - family?: number, - ) => { + let single: Capture | undefined; + lookup("example.test", {}, (err, address, family) => { single = { err, address, family }; - }) as unknown as Parameters[2]); + }); assert.equal(single!.err, null); assert.equal(single!.address, PINNED); assert.equal(single!.family, 4); // The `{ all: true }` form must return an address array. Hostname matching is // normalized, so an upper-cased request for the same host still resolves. - let all: Row | undefined; - lookup("EXAMPLE.TEST", { all: true }, (( - err: Error | null, - addresses?: Row[], - ) => { - all = { err, addresses }; - }) as unknown as Parameters[2]); + let all: Capture | undefined; + lookup("EXAMPLE.TEST", { all: true }, (err, address) => { + all = { err, address }; + }); assert.equal(all!.err, null); - assert.deepEqual(all!.addresses, [{ address: PINNED, family: 4 }]); + assert.deepEqual(all!.address, [{ address: PINNED, family: 4 }]); }); test("rejects a connect-time lookup for any other (rebound) host", () => { const lookup = createPinnedLookup("example.test", PINNED, 4); - let captured: Error | null | undefined; - lookup("evil.test", { all: true }, ((err: Error | null) => { - captured = err; - }) as unknown as Parameters[2]); - assert.ok(captured instanceof Error); - assert.match(captured!.message, /unpinned host/); + let captured: Capture | undefined; + lookup("evil.test", { all: true }, (err, address) => { + captured = { err, address }; + }); + assert.ok(captured!.err instanceof Error); + assert.match(captured!.err!.message, /unpinned host/); + // Refused, so there is no answer -- and the empty array is what says so. + // Passing an address here would hand a caller that ignored the error a + // route to the very host the pin exists to refuse. + assert.deepEqual(captured!.address, []); }); }); diff --git a/tests/validate-double-assertions.test.ts b/tests/validate-double-assertions.test.ts index 45b1f7c41c..a32f314cac 100644 --- a/tests/validate-double-assertions.test.ts +++ b/tests/validate-double-assertions.test.ts @@ -10,7 +10,11 @@ // in this codebase ("reads as never having reported") and a grep-based version // would fail on the sentence describing the problem it fixes. import { describe, expect, it } from "vitest"; -import { findDoubleAssertions } from "../scripts/validate-double-assertions.ts"; +import { + areaOf, + BUDGETS, + findDoubleAssertions, +} from "../scripts/validate-double-assertions.ts"; const scan = (source: string) => findDoubleAssertions("probe.ts", source); @@ -76,3 +80,71 @@ describe("findDoubleAssertions", () => { ).toHaveLength(2); }); }); + +describe("areaOf", () => { + const areas = Object.keys(BUDGETS); + + it("attributes a finding to its configured area", () => { + expect(areaOf("scripts/lib/worker-env.ts", areas)).toBe("scripts"); + expect(areaOf("src/r2-sql.ts", areas)).toBe("src"); + }); + + it("does not confuse a nested directory with a top-level one", () => { + // `packages/client/scripts/x.ts` counts against packages, not scripts -- + // otherwise a workspace could quietly spend another area's budget. + expect(areaOf("packages/client/scripts/x.ts", areas)).toBe("packages"); + }); + + it("files a NESTED area under itself, not its parent", () => { + expect(areaOf("apps/ui/src/routes/-gaps-page.tsx", areas)).toBe("apps/ui"); + }); + + it("picks the LONGEST matching area, not the first", () => { + // The failure this prevents: a shorter prefix absorbs the longer one's + // findings, so the longer budget reads as over-declared and the ratchet + // then demands you lower a ceiling that is actually being held. + const nested = ["apps", "apps/ui"]; + expect(areaOf("apps/ui/src/x.ts", nested)).toBe("apps/ui"); + expect(areaOf("apps/docs/src/x.ts", nested)).toBe("apps"); + }); + + it("does not match an area that is only a STRING prefix", () => { + // "apps/ui-legacy" must not spend "apps/ui"'s budget. + expect(areaOf("apps/ui-legacy/x.ts", ["apps/ui"])).toBe("apps"); + }); + + it("handles a repo-root file without inventing a directory", () => { + expect(areaOf("vitest.config.ts", areas)).toBe("vitest.config.ts"); + }); +}); + +describe("BUDGETS", () => { + it("holds every SWEPT area at zero", () => { + // Not a restatement of the constant: all five were driven to zero by + // #11339/#11361/#11368, and a nonzero entry here is a silent regression + // budget -- the thing this gate exists to prevent. If an area ever needs + // a ratchet again, this is the test that makes reintroducing one a + // deliberate edit rather than a quiet one. + for (const [area, budget] of Object.entries(BUDGETS)) { + if (area === "apps/ui") continue; // the live ratchet, see BUDGETS + expect(budget, `${area} has a nonzero budget`).toBe(0); + } + }); + + it("keeps apps/ui a ratchet that can only fall", () => { + // Named explicitly so raising it is a visible edit to this line rather + // than a quiet edit to a number. 83 is where #11368 left it. + expect(BUDGETS["apps/ui"]).toBeLessThanOrEqual(83); + }); + + it("covers every area a cast could hide in outside tests", () => { + expect(Object.keys(BUDGETS).sort()).toEqual([ + "apps/ui", + "packages", + "schemas-src", + "scripts", + "src", + "workers", + ]); + }); +}); diff --git a/workers/chain-firehose-hub.ts b/workers/chain-firehose-hub.ts index 3cdd943e60..61b965e897 100644 --- a/workers/chain-firehose-hub.ts +++ b/workers/chain-firehose-hub.ts @@ -107,6 +107,7 @@ export { CHAIN_FIREHOSE_TABLES, CHAIN_FIREHOSE_PUBLISHED_TABLES, } from "../src/chain-firehose-topics.ts"; +import type { ChainFirehoseHubState } from "./do-state.ts"; /** * Requested topics that no producer currently publishes, sorted for a stable @@ -694,7 +695,7 @@ export function formatChainFirehoseTopicNoticeFrame(topics: string[]): string { // graphql-ws fanout; every other broadcast population is unaffected, and the // next broadcast() retries getWebSockets() fresh. function safeGetWebSockets( - state: DurableObjectState, + state: ChainFirehoseHubState, tag?: string, ): WebSocket[] { try { @@ -910,7 +911,7 @@ interface ChainEventsGraphqlWsExtra { // below, not the whole class -- see #4982's issue body ("note any coverage // gap explicitly rather than skipping silently"). export class ChainFirehoseHub implements DurableObject { - state: DurableObjectState; + state: ChainFirehoseHubState; env: Env; sseClients: Set; // #5004 item 1: live SSE/WS connection count per client IP, mirroring @@ -973,7 +974,7 @@ export class ChainFirehoseHub implements DurableObject { // failure is the desired behavior, not a leak. lastCapturedHeadPollerError?: string; - constructor(state: DurableObjectState, env: Env) { + constructor(state: ChainFirehoseHubState, env: Env) { this.state = state; this.env = env; this.sseClients = new Set(); @@ -1240,8 +1241,20 @@ export class ChainFirehoseHub implements DurableObject { if (this.env.CHAIN_HEAD_POLL_ENABLED !== "true") return; // kill switch const rpcUrl = this.env.CHAIN_HEAD_RPC_URL || "https://archive.chain.opentensor.ai"; + // Checked, not asserted. `get` named a type for a value some + // earlier deploy wrote, and named it about storage that returns whatever + // was put there. A string cursor wedges this lane permanently and + // silently: heightsToEmit does `lastSeen + 1`, which CONCATENATES for a + // string, so "14" + 1 is "141" and the emit loop starts past the head + // and never runs. Measured, not reasoned -- heightsToEmit("14", 16) and + // heightsToEmit("14", 99) both return []. Nothing is emitted, so nothing + // is put back, so the bad cursor is never overwritten and the lane stays + // silent at every head forever, with no error to alarm on. + const storedLastSeen = await this.state.storage.get("head:last_seen"); const lastSeen = - (await this.state.storage.get("head:last_seen")) ?? null; + typeof storedLastSeen === "number" && Number.isFinite(storedLastSeen) + ? storedLastSeen + : null; const head = await fetchHeadNumber(rpcUrl); for (const height of heightsToEmit(lastSeen, head)) { // #9417: read the event count too, so a block is complete the moment diff --git a/workers/do-state.ts b/workers/do-state.ts new file mode 100644 index 0000000000..43d1d677b3 --- /dev/null +++ b/workers/do-state.ts @@ -0,0 +1,88 @@ +/** + * The `DurableObjectState` surfaces the hubs actually use. + * + * ## Why not just `DurableObjectState` + * + * Every hub in this directory declared its constructor as taking the whole + * thing, and every double outside the runtime therefore had to be asserted + * into it -- `{ storage: inMemoryDoStorage() } as unknown as DurableObjectState` + * in validate-mcp.ts, three times. That assertion is not a formality. It said + * the object had `blockConcurrencyWhile`, `abort`, `id`, `container`, `props`, + * a full `DurableObjectStorage` with fifteen members, and it said so about an + * object with one key. Nothing checked the claim, so nothing noticed when it + * stopped being close to true: + * + * - all three hubs call `state.waitUntil` for telemetry, inside a `try` that + * swallows the resulting TypeError. No double supplied it, so every + * validated MCP run emitted no usage telemetry at all and reported success. + * - `ChainFirehoseHub` reads and writes `state.storage` on the head-poll + * path. Its double was `{ getWebSockets: () => [] }` -- no storage at all. + * + * Both are the same failure: an assertion describing a shape nobody built. + * + * ## The shape of the fix + * + * Each hub names the surface it depends on, following `WaitUntilLike` and + * `HyperdriveLike` in src/pg-sql.ts. A real `DurableObjectState` satisfies + * these structurally, so the runtime is unaffected and Cloudflare still + * constructs the classes exactly as before. A double now has to implement what + * the hub calls -- and when a hub starts calling something new, the double + * fails to compile instead of throwing into a `catch` at runtime. + * + * These are deliberately per-hub rather than one shared `HubState`. The + * storage surfaces genuinely differ: the session and subnet hubs use the + * multi-key `get(keys)`/`put(entries)` overloads, the firehose hub uses the + * single-key `get(key)`/`put(key, value)` pair plus the alarm accessors. One + * merged interface would oblige every double to implement all of it, which is + * how the assertion got there in the first place. + * + * ## `unknown`, not a type argument + * + * `DurableObjectStorage.get` is generic, and the hubs used to name the type + * they expected -- `get("head:last_seen")`. Nothing enforces that. The + * value was written by a PREVIOUS deploy, so its shape is a contract with code + * that is no longer running, and the type argument is a claim about it rather + * than a check of it. mcp-session-hub.ts already says this at length in its own + * hydrate(), and then parses. These interfaces return `unknown` so every reader + * has to do the same thing, which also happens to be the only signature a + * double can implement without asserting. + */ +import type { WaitUntilLike } from "../src/pg-sql.ts"; + +/** Deferred telemetry. A Durable Object has no `ExecutionContext`, so the hubs + * park fire-and-forget work on the state itself. */ +export type HubStateBase = WaitUntilLike; + +/** Multi-key storage: `get(keys)` returns a Map, `put(entries)` takes a record. + * Both are real `DurableObjectStorage` overloads. */ +export interface KeyedDoStorage { + get(keys: string[]): Promise>; + put(entries: Record): Promise; +} + +/** What `McpSessionHub` uses: keyed storage plus the idle-expiry alarm. */ +export interface McpSessionHubState extends HubStateBase { + storage: KeyedDoStorage & { + setAlarm(scheduledTime: number | Date): Promise; + }; +} + +/** What `SubnetStatusHub` uses. No alarm: its state expires with the sessions + * that hold it, not on a timer. */ +export interface SubnetStatusHubState extends HubStateBase { + storage: KeyedDoStorage; +} + +/** What `ChainFirehoseHub` uses: single-key storage for the head cursor, the + * alarm accessors that drive the poll loop, and the hibernatable-WebSocket + * pair that lets it survive eviction with connections open. */ +export interface ChainFirehoseHubState extends HubStateBase { + storage: { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + getAlarm(): Promise; + setAlarm(scheduledTime: number | Date): Promise; + }; + acceptWebSocket(ws: WebSocket, tags?: string[]): void; + getWebSockets(tag?: string): WebSocket[]; +} diff --git a/workers/mcp-session-hub.ts b/workers/mcp-session-hub.ts index 22e227b2b9..a3d71602c1 100644 --- a/workers/mcp-session-hub.ts +++ b/workers/mcp-session-hub.ts @@ -67,6 +67,7 @@ import { McpSessionStateSchema, HubSessionUriBodySchema, } from "../schemas-src/internal-wire.ts"; +import type { McpSessionHubState } from "./do-state.ts"; export const MCP_CHAIN_STREAM_RESOURCE_URI = "metagraph://chain/stream"; @@ -163,7 +164,7 @@ const SESSION_HUB_ROUTES: Record = { }; export class McpSessionHub implements DurableObject { - state: DurableObjectState; + state: McpSessionHubState; env: Env; subscribedUris: Set; pendingUris: Set; @@ -175,7 +176,7 @@ export class McpSessionHub implements DurableObject { hydrated: boolean; sessionId: string | null; - constructor(state: DurableObjectState, env: Env) { + constructor(state: McpSessionHubState, env: Env) { this.state = state; this.env = env; this.subscribedUris = new Set(); @@ -198,9 +199,7 @@ export class McpSessionHub implements DurableObject { if (this.hydrated) return; // Keys DERIVED from the schema -- see MCP_SESSION_STATE_KEYS. A // hand-written list here was the third place the field set was stated. - const stored = await this.state.storage.get< - string | string[] | number | boolean - >([...MCP_SESSION_STATE_KEYS]); + const stored = await this.state.storage.get([...MCP_SESSION_STATE_KEYS]); // PARSED, NOT CAST (#11194). These come back from a PREVIOUS deploy's // `persist()`, so their shape is a contract with code that is no longer // running. `as string[]` over a value that is actually a string would be diff --git a/workers/subnet-status-hub.ts b/workers/subnet-status-hub.ts index 56fd9ad226..cec38ef9c9 100644 --- a/workers/subnet-status-hub.ts +++ b/workers/subnet-status-hub.ts @@ -32,6 +32,7 @@ import { HubRequiredSessionIdBodySchema, HubSubnetSessionBodySchema, } from "../schemas-src/internal-wire.ts"; +import type { SubnetStatusHubState } from "./do-state.ts"; type NetuidIndex = Map>; type SessionIndex = Map>; @@ -128,13 +129,13 @@ export function hydrateSubscriptionIndex(stored: unknown): { } export class SubnetStatusHub implements DurableObject { - state: DurableObjectState; + state: SubnetStatusHubState; env: Env; byNetuid: NetuidIndex; sessionByNetuid: SessionIndex; hydrated: boolean; - constructor(state: DurableObjectState, env: Env) { + constructor(state: SubnetStatusHubState, env: Env) { this.state = state; this.env = env; this.byNetuid = new Map();