1/**2 * Fetch wrapper for the FinData OS API (same origin, prefix /api/v1).3 * - cookie session (`credentials: same-origin`)4 * - X-CSRF-Token on every non-GET request (kept in memory + sessionStorage)5 * - error normalization: throws ApiError {code, message, request_id, status, extra}6 */7export const API_PREFIX = "/api/v1";89const CSRF_KEY = "findata.csrf";10let csrfToken: string | null = null;1112export function getCsrf(): string | null {13 if (csrfToken) return csrfToken;14 if (typeof window !== "undefined") {15 try {16 csrfToken = window.sessionStorage.getItem(CSRF_KEY);17 } catch {18 csrfToken = null;19 }20 }21 return csrfToken;22}2324export function setCsrf(token: string | null | undefined) {25 csrfToken = token ?? null;26 if (typeof window === "undefined") return;27 try {28 if (token) window.sessionStorage.setItem(CSRF_KEY, token);29 else window.sessionStorage.removeItem(CSRF_KEY);30 } catch {31 /* storage unavailable */32 }33}3435export class ApiError extends Error {36 code: string;37 status: number;38 request_id: string | null;39 extra: Record<string, unknown>;40 constructor(status: number, code: string, message: string, request_id: string | null, extra: Record<string, unknown> = {}) {41 super(message);42 this.name = "ApiError";43 this.status = status;44 this.code = code;45 this.request_id = request_id;46 this.extra = extra;47 }48}4950export function isApiError(e: unknown): e is ApiError {51 return e instanceof ApiError;52}5354export type Query = Record<string, string | number | boolean | null | undefined>;5556export function qs(params?: Query): string {57 if (!params) return "";58 const u = new URLSearchParams();59 for (const [k, v] of Object.entries(params)) {60 if (v === undefined || v === null || v === "") continue;61 u.set(k, String(v));62 }63 const s = u.toString();64 return s ? `?${s}` : "";65}6667interface RequestOptions {68 method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";69 body?: unknown;70 params?: Query;71 signal?: AbortSignal;72}7374async function parseError(res: Response): Promise<ApiError> {75 let payload: unknown = null;76 try {77 payload = await res.json();78 } catch {79 /* not JSON */80 }81 const err = (payload as { error?: Record<string, unknown> } | null)?.error;82 if (err && typeof err === "object") {83 const { code, message, request_id, ...extra } = err as Record<string, unknown>;84 return new ApiError(res.status, String(code ?? "http_error"), String(message ?? res.statusText), (request_id as string) ?? null, extra);85 }86 return new ApiError(res.status, "http_error", res.statusText || `HTTP ${res.status}`, res.headers.get("x-request-id"));87}8889export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {90 const method = opts.method ?? "GET";91 const headers: Record<string, string> = { Accept: "application/json" };92 if (opts.body !== undefined) headers["Content-Type"] = "application/json";93 if (method !== "GET") {94 const t = getCsrf();95 if (t) headers["X-CSRF-Token"] = t;96 }97 const url = (path.startsWith("/api/") ? path : API_PREFIX + path) + qs(opts.params);98 const res = await fetch(url, {99 method,100 headers,101 credentials: "same-origin",102 body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,103 signal: opts.signal,104 });105 if (!res.ok) throw await parseError(res);106 if (res.status === 204) return undefined as T;107 const text = await res.text();108 if (!text) return undefined as T;109 return JSON.parse(text) as T;110}111112export const api = {113 get: <T>(path: string, params?: Query, signal?: AbortSignal) => request<T>(path, { params, signal }),114 post: <T>(path: string, body?: unknown, params?: Query) => request<T>(path, { method: "POST", body, params }),115 put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),116 del: <T>(path: string) => request<T>(path, { method: "DELETE" }),117};118119// ---------------------------------------------------------------- types (mirroring the FastAPI routers)120121export interface Me {122 kind: "user" | "api_key" | "anonymous";123 email: string | null;124 role: string | null;125 scopes: string[];126 mfa_passed: boolean;127 totp_enabled: boolean | null;128 csrf_token: string | null;129}130131export interface LoginOut {132 ok: boolean;133 mfa_required: boolean;134 csrf_token: string;135 email: string;136 role: string;137}138139export interface HealthAlert {140 severity: "info" | "warning" | "error";141 code: string;142 message: string;143}144145export interface HealthReport {146 status: "ok" | "warning" | "degraded";147 checked_at?: string;148 alerts: HealthAlert[];149 components: {150 postgres?: string;151 lake?: { root: string; free_gb: number; curated_gb: number; raw_gb: number; soft_limit_gb: number; hard_min_free_gb: number };152 ingest_worker?: string;153 backup?: { last: string | null; age_hours?: number; verified?: boolean | null };154 ssd_smart?: string;155 };156 datasets?: {157 dataset_id: string;158 version: number | null;159 last_session: string | null;160 days_since_last_session: number | null;161 vendor_last_update: string | null;162 vendor_lag_days: number | null;163 }[];164}165166export interface HealthSummary {167 datasets: { active: number; planned: number; unsupported: number; pending: number };168 instruments: { n: number; active: number };169 bars_1m: { symbols: number; sessions: number; bars: number | null; last: string | null };170 jobs: Record<string, number>;171 recent_jobs: Job[];172 fundamentals: { entities: number; facts: number };173 anomalies_7d: Record<string, number>;174 health: HealthReport;175}176177export type DatasetStatus = "active" | "planned" | "pending_configuration" | "unsupported" | "paused";178179export interface Dataset {180 id: string;181 name: string;182 description: string | null;183 kind: string;184 asset_class: string | null;185 interval: string | null;186 adjustment: string | null;187 license_class: string;188 license_text: string;189 status: DatasetStatus;190 timestamp_convention: string | null;191 timezone: string | null;192 current_version: number | null;193 snapshot_id?: string | null;194 file_count?: number | null;195 row_count?: number | null;196 bytes?: number | null;197 version_created_at?: string | null;198 first_session?: string | null;199 last_session?: string | null;200 symbols?: number | null;201 source_name?: string;202 source_id?: string;203 vendor?: string;204 product?: string;205 notes?: string | null;206 [k: string]: unknown;207}208209export interface DatasetVersion {210 version: number;211 snapshot_id: string | null;212 file_count: number | null;213 row_count: number | null;214 bytes: number | null;215 created_at: string;216 note: string | null;217}218219export interface DatasetDetail {220 data: Dataset;221 versions: DatasetVersion[];222 files: { layout: string; n: number; rows: number | null; bytes: number | null; d0: string | null; d1: string | null }[];223 checkpoints: { key: string; value: unknown; updated_at: string }[];224}225226export interface Instrument {227 instrument_id: string;228 symbol: string;229 name: string | null;230 asset_class: string;231 status: string;232 first_date: string | null;233 last_date: string | null;234 currency: string | null;235 [k: string]: unknown;236}237238export interface InstrumentDetail {239 instrument: Instrument | null;240 aliases: Record<string, unknown>[];241 corporate_actions: { action_type: string; ex_date: string; value: unknown }[];242 coverage: { dataset_id: string; sessions: number; d0: string; d1: string; bars: number }[];243 fundamentals: { facts: number; last_period: string | null; last_filing: string | null } | null;244}245246export interface Bar {247 timestamp_utc: string;248 session_date: string;249 open: number;250 high: number;251 low: number;252 close: number;253 volume: number;254 vwap?: number | null;255 trade_count?: number | null;256 quality_flags: string[];257 adjustment_type?: string;258 revision_id?: number;259 [k: string]: unknown;260}261262export interface BarsOut {263 data: Bar[];264 meta: {265 symbol: string;266 interval: string;267 adjustment: string;268 start: string;269 end: string;270 rows: number;271 rows_matching: number;272 adjustment_notes: string[];273 timestamp_convention: string;274 license: string;275 version?: number;276 files_scanned?: number;277 [k: string]: unknown;278 };279 next_cursor: string | null;280}281282export interface SessionRow {283 session_date: string;284 bar_count: number;285 expected_bars: number | null;286 first_ts: string | null;287 last_ts: string | null;288 flags: string[] | null;289 symbol?: string;290}291292export interface Heatmap {293 sessions: { session_date: string; symbols: number; bars: number; completeness: number | null; non_trading_rows: number }[];294 top_symbols: { symbol: string; sessions: number; bars: number; completeness: number | null }[];295 meta: { dataset_id: string; start: string; end: string };296}297298export interface QualityOut {299 latest: Record<string, unknown> | null;300 anomalies_by_code_30d: { code: string; severity: string; n: number; rows: number | null }[];301 reports: { id: string; created_at: string; rows_checked: number; anomalies: number; summary: unknown }[];302 codes: Record<string, string>;303}304305export interface Anomaly {306 id?: string;307 code: string;308 severity: string;309 symbol: string | null;310 session_date?: string | null;311 detail: Record<string, unknown> | null;312 created_at: string;313 [k: string]: unknown;314}315316export interface QueryOut {317 columns: { name: string; type: string }[];318 rows: Record<string, unknown>[];319 meta: { truncated?: boolean; rows: number; elapsed_ms: number; views: string[]; engine: string; [k: string]: unknown };320}321322export interface ExportRow {323 id: string;324 status: "queued" | "running" | "ready" | "failed" | "expired";325 request: { dataset_id: string; symbols: string[]; start: string; end: string; adjustment: string; format: string; version?: number };326 format: string;327 bytes: number | null;328 row_count: number | null;329 created_at: string;330 finished_at: string | null;331 expires_at: string | null;332 error: string | null;333 snapshot: unknown;334 download?: string;335}336337export interface Fact {338 entity_id: string;339 metric: string;340 value: number | null;341 unit: string;342 currency: string | null;343 fiscal_period_start: string | null;344 fiscal_period_end: string;345 fiscal_period_type: string;346 fiscal_year: number | null;347 fiscal_period: string | null;348 filing_date: string | null;349 published_at: string | null;350 available_at: string;351 observed_at: string;352 source_document: string | null;353 source_id: string;354 revision_id: number;355 restatement_flag: boolean;356 superseded_at: string | null;357}358359export interface FundamentalsOut {360 data: Fact[];361 entity: Record<string, unknown> | null;362 meta: { as_of: string; rows: number; facts_hidden_by_as_of: number; pit_policy: string; license: string };363}364365export interface PeriodsOut {366 periods: {367 fiscal_period_end: string;368 fiscal_period_type: string;369 fiscal_year: number | null;370 fiscal_period: string | null;371 filing_date: string | null;372 available_at: string;373 metrics: number;374 max_revision: number;375 restated: boolean;376 }[];377 earnings: { event_date: string; eps_estimate: number | null; eps_reported: number | null; release_time: string | null }[];378}379380export interface Job {381 id: string;382 kind: string;383 dataset_id: string | null;384 params?: Record<string, unknown>;385 priority?: number;386 status: string;387 attempts?: number;388 max_attempts?: number;389 next_run_at?: string | null;390 created_at?: string;391 started_at: string | null;392 finished_at: string | null;393 last_error: string | null;394 progress?: Record<string, unknown> | null;395 result?: Record<string, unknown> | null;396 created_by?: string | null;397}398399export interface ApiKey {400 id: string;401 name: string;402 prefix: string;403 scopes: string[];404 rate_limit_per_minute: number;405 expires_at: string | null;406 last_used_at: string | null;407 revoked_at: string | null;408 created_at: string;409 email: string;410}411412export interface UsageRow {413 principal: string;414 day: string;415 requests: number;416 rows_returned: number;417 bytes_exported: number;418}419420export interface AuditRow {421 id?: string;422 at: string;423 action: string;424 actor?: string | null;425 principal?: string | null;426 target?: string | null;427 outcome?: string | null;428 ip?: string | null;429 request_id?: string | null;430 detail?: Record<string, unknown> | null;431 [k: string]: unknown;432}433434export interface StorageOut {435 lake_root: string;436 volume: { total_gb: number; free_gb: number };437 budget: { soft_limit_gb: number; hard_min_free_gb: number };438 curated_bytes: number;439 raw_bytes: number;440 retired_bytes: number;441 exports_bytes: number;442 per_dataset: { dataset_id: string; layout: string; files: number; bytes: number | null; rows: number | null }[];443 raw_archives: { status: string; n: number; bytes: number | null }[];444 exports: { status: string; n: number; bytes: number }[];445 retired_files: { n: number; bytes: number };446 postgres_bytes: number;447 last_backup: { value: Record<string, unknown>; updated_at: string } | null;448 capacity_model: { bytes_per_1m_bar_curated: number; note: string };449}450451export interface SettingsOut {452 settings: { key: string; value: unknown; updated_at: string }[];453 env: {454 env: string;455 lake: string;456 public_url: string;457 backup_target: string;458 vendor_configured: boolean;459 sec_edgar_configured: boolean;460 ai_providers: { anthropic: boolean; openai: boolean };461 };462}463