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
text/typescript
/**
* # 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,
};
}