UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

517 lines (474 loc) 22.5 kB
/** * # adapters/lake — the data-lake accessor (chDB over object storage) * * Kestrel's durable data plane is **Parquet on S3-compatible object storage** (e.g. * Cloudflare R2, GCS, MinIO), queried **in-process by chDB** — embedded ClickHouse, SQL over * Parquet — natively under Bun. This is the read-only accessor Grade and sim read history * through (ARCHITECTURE §4 "Adapters — Lake"). No second language. * * ## Object storage is the ONLY source of truth * `readParquet` / `listParquet` / `exists` read the **canonical** store — the configured * `s3()` remote — by default. Local disk is never a production data source. It appears in * exactly two demoted roles: * * - **Engine cache** (`cacheDir`): the first read of an object fetches it from object storage * and materializes a local segment; subsequent reads hit that segment with **no network**, * LRU-evicted within `cacheMaxBytes`. The result is still stamped `source: "s3"` — the cache * is transparent, the canonical source is object storage. The active mechanism is reported * on `Lake.cache` (`"adapter" | "chdb-fs" | "none"`). * - **Explicit dev/fixture root** (`devLocalRoot`, env `LAKE_DEV_LOCAL_ROOT`): an *opt-in* * creds-free mode for tests/CI. When set, reads resolve local-first and are stamped * `source: "dev-local"`. When unset it is **never** consulted. * * ### Why an adapter cache and not chDB's filesystem cache * chDB 3.1.0's node binding exposes no config path to *declare* a named ClickHouse filesystem * cache, so `SETTINGS enable_filesystem_cache=1, filesystem_cache_name='…'` is silently * ignored (verified: a repeat `s3()` read still re-fetches every byte). We therefore run an * **adapter-level content-addressed segment cache**: on a miss we materialize the object once * via `INSERT INTO FUNCTION file(<segment>, Parquet) SELECT * FROM s3(…)` (one remote fetch) * and read all subsequent queries from the local segment. Verified hermetically: MISS = full * fetch, HIT = 0 network requests, ~easily >10× faster (see the cache test). Segments are * keyed by a hash of the object URL; a lake object at a given key is treated **immutable** * (versioned lakes rev the path), so a changed body needs a new key or a cache clear. * * ## Loud, typed failures (fail-closed, RUNTIME §0) * A missing object, an unreadable path, or a bad credential is **never** an empty result — it * throws {@link LakeReadError}. An empty array means "the query legitimately matched no rows". * * ## ⚠️ TIMEZONE PARITY — a known data-corruption class, read this * Timezone-aware timestamps stored in Parquet (ClickHouse `DateTime64(_, 'UTC')` and friends) * read back as **UTC instants**. That is correct and lossless — but it means **any * hour-of-day / minute-of-day / calendar-date derivation must localize IN SQL**, e.g. * `toHour(ts, 'America/New_York')`, `toStartOfDay(ts, 'America/New_York')`. Deriving a * wall-clock field from the raw UTC instant (`toHour(ts)`) silently drifts by the UTC offset — * an 18:30 New York print reads back as hour 22, and an "RTH-only" filter written naively * leaks overnight bars. The market session's timezone is the author's to supply, in the query. * (See the tz-parity test in `tests/lake.test.ts`.) * * ## Secrets * Access keys are held in memory only, never logged, never written to disk. The `s3()` table * function requires the secret in the SQL string handed to the engine (in-memory only); any * SQL surfaced in an error message is **redacted** first. Cache segments are Parquet data * keyed by a URL hash — they carry no secret. */ import { mkdirSync, readdirSync, renameSync, rmSync, statSync, utimesSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { resolveLakeConfig, type LakeConfig, type LakeConfigInput, type LakeS3Config, } from "./config.ts"; import { LakeReadError, type LakeSource } from "./errors.ts"; export { LakeReadError, LakeConfigError, type LakeSource } from "./errors.ts"; export { resolveLakeConfig, parseRcloneRemote, defaultCacheDir, DEFAULT_CACHE_MAX_BYTES, type LakeConfig, type LakeConfigInput, type LakeS3Config, } from "./config.ts"; /** * The byte-level half of the lake: raw objects (`.jsonl` tapes, `.csv.zst` vendor pulls) through the * SAME config, into a BOUNDED local cache. Re-exported here for discoverability — but note it is * deliberately **chDB-free**, so a caller that only needs bytes (the tape-corpus loaders) imports * `./objects.ts` DIRECTLY and never pulls the native engine into its graph. */ export { objectStore, sha256File, type ObjectStore, type ObjectStoreOptions, type LakeObject, type EnsureOptions, } from "./objects.ts"; /** One row of a query result — chDB JSON objects, column name → value. */ export type Row = Record<string, unknown>; /** Which caching mechanism the {@link Lake} instance actually runs. */ export type LakeCacheMechanism = "chdb-fs" | "adapter" | "none"; /** Options for {@link Lake.readParquet}. */ export interface ReadParquetOptions { /** Project to these columns (validated identifiers). Omitted ⇒ `SELECT *`. */ columns?: readonly string[]; } /** The result of a Parquet read: the rows plus the path that produced them. */ export interface ParquetRead { rows: Row[]; /** * The canonical source: `"s3"` for object storage (whether or not it was served warm from * the engine cache) or `"dev-local"` for the explicit dev/fixture root. Never silent. */ source: LakeSource; /** The lake-relative key that was read. */ rel: string; /** For an `"s3"` read: did the engine cache serve it warm (no network)? */ cached?: boolean; } /** * The lake accessor. All methods are async: the S3 paths do real network I/O and chDB runs * off the event loop, so nothing here blocks the runtime. */ export interface Lake { /** The resolved configuration (secrets included in memory; never serialize this). */ readonly config: LakeConfig; /** Which cache mechanism is active on this instance. */ readonly cache: LakeCacheMechanism; /** Run arbitrary SQL through chDB; returns parsed JSON rows. Throws {@link LakeReadError}. */ query(sql: string): Promise<Row[]>; /** Read one Parquet object from the canonical store (cache-warmed), or the dev root. */ readParquet(rel: string, opts?: ReadParquetOptions): Promise<ParquetRead>; /** Does `rel` exist? Dev root (if set), else warm cache, else a cheap S3 probe. */ exists(rel: string): Promise<boolean>; /** List Parquet keys under `relPrefix` — S3 glob ∪ the dev root (when set). */ listParquet(relPrefix: string): Promise<string[]>; } // ───────────────────────────────────────────────────────────────────────────── // SQL construction — validated identifiers, escaped literals, redacted secrets // ───────────────────────────────────────────────────────────────────────────── const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Validate a column identifier and backtick-quote it; reject anything injection-shaped. */ function qIdent(name: string): string { if (!IDENT.test(name)) { throw new LakeReadError(`invalid column identifier: ${JSON.stringify(name)}`); } return `\`${name}\``; } /** Single-quote a path/URL literal after rejecting quote/backslash/newline (no injection). */ function qLiteral(value: string, rel: string): string { if (/['\\\n\r\0]/.test(value)) { throw new LakeReadError(`unsafe characters in path: ${JSON.stringify(value)}`, { rel }); } return `'${value}'`; } /** Reject a relative key that is empty or escapes the root; return it normalized. */ function checkRel(rel: string): string { const r = rel.trim(); if (r.length === 0) throw new LakeReadError("empty relative key"); if (r.startsWith("/") || r.split("/").includes("..")) { throw new LakeReadError(`relative key must stay inside the lake: ${JSON.stringify(rel)}`, { rel, }); } return r; } const columnList = (cols: readonly string[] | undefined): string => cols && cols.length > 0 ? cols.map(qIdent).join(", ") : "*"; /** `${endpoint}/${bucket}/${prefix}/${rel}` with no doubled or missing slashes. */ function s3Url(s3: LakeS3Config, rel: string): string { const parts = [s3.endpoint, s3.bucket, s3.prefix, rel].filter((p) => p.length > 0); return parts.join("/"); } /** The S3 key (`bucket/prefix`), used to strip `_path` back to a lake-relative key when listing. */ const s3KeyBase = (s3: LakeS3Config): string => [s3.bucket, s3.prefix].filter((p) => p.length > 0).join("/"); /** The `s3(url, key, secret, Parquet)` table expression + a secret-redacted twin for errors. */ function s3Table(s3: LakeS3Config, url: string, rel: string): { sql: string; redacted: string } { const sql = `s3(${qLiteral(url, rel)}, ${qLiteral(s3.accessKeyId, rel)}, ${qLiteral(s3.secretAccessKey, rel)}, Parquet)`; const redacted = `s3('${url}', '***', '***', Parquet)`; return { sql, redacted }; } // ───────────────────────────────────────────────────────────────────────────── // Engine // ───────────────────────────────────────────────────────────────────────────── /** * chDB is an **optional native dependency** (declared in `optionalDependencies`) that is materialized * ONLY for this lake/analytics path — it is *not* installed on the core CLI / session runtime, and * nothing in the CLI module graph imports the lake today. Loading it at the top of the module would make * this file **crash at import time** wherever chDB is absent; that violates fail-closed (RUNTIME §0), * which requires a loud, reasoned error at the point of USE, never a silent module-load crash. So the * engine is loaded LAZILY here, at the first query. A caller that only imports the module pays nothing; * a caller that reaches a query with chDB absent gets a typed {@link LakeReadError} that names chDB as * the unmet optional dependency for the lake path. The specifier is held in a variable so the type * checker does not require `chdb` to be resolvable once it is dropped from the install (the core build * ships without it). */ interface ChdbEngine { queryAsync(sql: string, opts: { format: string }): Promise<{ json<T>(): T }>; } const CHDB_SPECIFIER = "chdb"; let chdbEnginePromise: Promise<ChdbEngine> | undefined; async function loadChdb(): Promise<ChdbEngine> { if (chdbEnginePromise === undefined) { chdbEnginePromise = import(CHDB_SPECIFIER).then( (mod: unknown): ChdbEngine => { const m = mod as { default?: ChdbEngine } & ChdbEngine; return (m.default ?? m) as ChdbEngine; }, (cause: unknown): never => { chdbEnginePromise = undefined; // allow a later attempt (e.g. after an install) throw new LakeReadError( "chdb — the optional native ClickHouse engine for the lake/analytics path — is not installed. " + "Install the optional dependency to run lake queries (e.g. `npm install chdb`), or use the " + "chdb-free bytes accessor (`objectStore` from adapters/lake/objects) for raw objects.", { cause }, ); }, ); } return chdbEnginePromise; } /** * Run SQL through chDB with JSON output and return the `data` rows. `redactedSql` (if given) * is what appears in an error — the raw `sql` may carry a secret and is never surfaced. */ async function runRows( sql: string, ctx: { redactedSql?: string; rel?: string; source?: LakeSource } = {}, ): Promise<Row[]> { const chdb = await loadChdb(); // throws a typed LakeReadError if the optional engine is absent try { const res = await chdb.queryAsync(sql, { format: "JSON" }); const parsed = res.json<{ data?: Row[] }>(); return parsed.data ?? []; } catch (cause) { const shown = ctx.redactedSql ?? sql; throw new LakeReadError(`lake query failed: ${shown}`, { cause, ...(ctx.rel === undefined ? {} : { rel: ctx.rel }), ...(ctx.source === undefined ? {} : { source: ctx.source }), }); } } /** Run a statement (INSERT/materialize) for its side effect; redact secrets in errors. */ async function runStatement( sql: string, ctx: { redactedSql: string; rel: string; source: LakeSource }, ): Promise<void> { const chdb = await loadChdb(); // throws a typed LakeReadError if the optional engine is absent try { await chdb.queryAsync(sql, { format: "JSON" }); } catch (cause) { throw new LakeReadError(`lake fetch failed: ${ctx.redactedSql}`, { cause, rel: ctx.rel, source: ctx.source, }); } } const isFile = (abs: string): boolean => { try { return statSync(abs).isFile(); } catch { return false; } }; // ───────────────────────────────────────────────────────────────────────────── // Factory // ───────────────────────────────────────────────────────────────────────────── /** Construct a {@link Lake} from an optional override (falls back to the environment). */ export function lake(input: LakeConfigInput = {}): Lake { const config = resolveLakeConfig(input); // Determine the cache mechanism at construction. chDB's node binding cannot declare a // filesystem cache, so "chdb-fs" is never selected here; caching is our adapter segment // store, unless the cap is 0 (disabled) or the cache dir is unusable. let cache: LakeCacheMechanism = "none"; if (config.cacheMaxBytes > 0) { try { mkdirSync(config.cacheDir, { recursive: true }); cache = "adapter"; } catch { cache = "none"; } } const requireS3 = (rel: string, source: LakeSource): LakeS3Config => { if (config.s3 === undefined) { throw new LakeReadError( `object storage is the canonical source but no S3 remote is configured ` + `(need LAKE_S3_ENDPOINT/BUCKET + credentials): ${rel}`, { rel, source }, ); } return config.s3; }; /** Stable, secret-free cache-segment path for an object URL. */ const segmentPath = (url: string): string => join(config.cacheDir, `${createHash("sha256").update(url).digest("hex").slice(0, 40)}.parquet`); /** LRU-evict cache segments by atime until total size is within the cap. */ function evictToCap(): void { if (cache !== "adapter") return; let entries: { path: string; size: number; atime: number }[]; try { entries = (readdirSync(config.cacheDir) as string[]) .filter((f) => f.endsWith(".parquet")) .map((f) => { const p = join(config.cacheDir, f); const st = statSync(p); return { path: p, size: st.size, atime: st.atimeMs }; }); } catch { return; } let total = entries.reduce((a, e) => a + e.size, 0); if (total <= config.cacheMaxBytes) return; entries.sort((a, b) => a.atime - b.atime); // oldest-accessed first for (const e of entries) { if (total <= config.cacheMaxBytes) break; try { rmSync(e.path, { force: true }); total -= e.size; } catch { /* a racing reader may hold it; skip */ } } } /** * Ensure the object is materialized to a local cache segment (one remote fetch on miss) and * return `{ segment, cached }`. On a hit no network happens. */ async function warmSegment( s3: LakeS3Config, url: string, rel: string, ): Promise<{ segment: string; cached: boolean }> { const segment = segmentPath(url); if (isFile(segment)) { try { const now = new Date(); utimesSync(segment, now, now); // bump recency for LRU } catch { /* best-effort */ } return { segment, cached: true }; } const { sql: table, redacted } = s3Table(s3, url, rel); const tmp = `${segment}.${process.pid}.${Date.now()}.tmp`; await runStatement( `INSERT INTO FUNCTION file(${qLiteral(tmp, rel)}, Parquet) SELECT * FROM ${table}`, { redactedSql: `INSERT INTO FUNCTION file('<segment>', Parquet) SELECT * FROM ${redacted}`, rel, source: "s3", }, ); try { renameSync(tmp, segment); } catch (cause) { rmSync(tmp, { force: true }); throw new LakeReadError(`could not commit cache segment for ${rel}`, { cause, rel, source: "s3", }); } evictToCap(); return { segment, cached: false }; } async function readParquet(rel: string, opts: ReadParquetOptions = {}): Promise<ParquetRead> { const key = checkRel(rel); const cols = columnList(opts.columns); // Explicit dev/fixture mode: local-first, only when devLocalRoot is set. if (config.devLocalRoot !== undefined) { const abs = join(config.devLocalRoot, key); if (isFile(abs)) { const rows = await runRows(`SELECT ${cols} FROM file(${qLiteral(abs, rel)}, Parquet)`, { rel, source: "dev-local", }); return { rows, source: "dev-local", rel: key }; } } // Canonical: object storage, warmed through the engine cache when enabled. const s3 = requireS3(rel, "s3"); const url = s3Url(s3, key); if (cache === "adapter") { const { segment, cached } = await warmSegment(s3, url, rel); const rows = await runRows(`SELECT ${cols} FROM file(${qLiteral(segment, rel)}, Parquet)`, { rel, source: "s3", }); return { rows, source: "s3", rel: key, cached }; } // Caching disabled: read directly from object storage every time. const { sql: table, redacted } = s3Table(s3, url, rel); const rows = await runRows(`SELECT ${cols} FROM ${table}`, { redactedSql: `SELECT ${cols} FROM ${redacted}`, rel, source: "s3", }); return { rows, source: "s3", rel: key, cached: false }; } async function exists(rel: string): Promise<boolean> { const key = checkRel(rel); if (config.devLocalRoot !== undefined && isFile(join(config.devLocalRoot, key))) { return true; } if (config.s3 === undefined) { // No S3 remote AND no dev root ⇒ no canonical source at all: that is a config fault, // not an absent object. Fail closed like readParquet's requireS3 — never mask a // misconfigured/uncredentialed lake as genuinely-absent history to a Grade/sim caller. // requireS3 always throws here (config.s3 is undefined); invoke it for that // side-effect rather than returning it — its LakeS3Config result is never a boolean. if (config.devLocalRoot === undefined) requireS3(rel, "s3"); // A dev root IS configured and the key is not under it ⇒ genuinely absent. return false; } const url = s3Url(config.s3, key); if (cache === "adapter" && isFile(segmentPath(url))) return true; // warm ⇒ definitely exists // S3 probe: the `One` format lists the matched object WITHOUT reading its contents (one // row per file), a cheap HEAD-equivalent — it does not scan the Parquet. A genuinely-absent // key makes s3() raise a not-found error, read here as `false`; any other failure (bad // creds, network) stays loud as a LakeReadError. const sql = `SELECT 1 FROM s3(${qLiteral(url, rel)}, ` + `${qLiteral(config.s3.accessKeyId, rel)}, ${qLiteral(config.s3.secretAccessKey, rel)}, One) LIMIT 1`; const redactedSql = `SELECT 1 FROM s3('${url}', '***', '***', One) LIMIT 1`; try { const rows = await runRows(sql, { redactedSql, rel, source: "s3" }); return rows.length > 0; } catch (err) { const msg = err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : ""; if (/not found|no such|does not exist|Cannot stat|404|NoSuchKey/i.test(msg)) return false; throw err; } } function devLocalList(relPrefix: string): string[] { if (config.devLocalRoot === undefined) return []; let entries: string[]; try { entries = readdirSync(config.devLocalRoot, { recursive: true }) as string[]; } catch { return []; } return entries .map((e) => e.split("\\").join("/")) .filter((e) => e.endsWith(".parquet") && e.startsWith(relPrefix)); } async function s3List(relPrefix: string): Promise<string[]> { if (config.s3 === undefined) return []; // Recursive glob under prefix; `**` spans '/' in ClickHouse. `One` format lists without // reading file bodies. `_path` is `bucket/key`; strip the key base back to a lake key. const base = s3Url(config.s3, "").replace(/\/+$/, ""); const glob = `${base}/${relPrefix}**.parquet`; const sql = `SELECT DISTINCT _path AS p FROM s3(${qLiteral(glob, relPrefix)}, ` + `${qLiteral(config.s3.accessKeyId, relPrefix)}, ${qLiteral(config.s3.secretAccessKey, relPrefix)}, One)`; const redactedSql = `SELECT DISTINCT _path AS p FROM s3('${glob}', '***', '***', One)`; const rows = await runRows(sql, { redactedSql, source: "s3" }); const keyBase = `${s3KeyBase(config.s3)}/`; const out: string[] = []; for (const r of rows) { const p = typeof r.p === "string" ? r.p : undefined; if (p === undefined) continue; out.push(p.startsWith(keyBase) ? p.slice(keyBase.length) : p); } return out; } async function listParquet(relPrefix: string): Promise<string[]> { const prefix = relPrefix.trim(); const s3keys = await s3List(prefix); const union = new Set<string>([...devLocalList(prefix), ...s3keys]); return [...union].sort(); } return { config, cache, query: (sql: string) => runRows(sql), readParquet, exists, listParquet, }; }