UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

320 lines • 14.3 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { efbig } from "../../errors/fs.js"; import { PathSpec } from "../../types.js"; import { abortable, sleep } from "../../utils/abort.js"; import { encodeBase64 } from "../../utils/base64.js"; import { makeRead, perKind, } from "../hierarchy/read.js"; import { jsonBytes } from "../render/json.js"; import { buildDatabaseJson, buildEntitySchemaJson } from "./_schema_json.js"; import { countRows, estimateSize, fetchBoundedRows, fetchColumns, fetchLastRows, fetchRowKey, fetchRows, fetchRowsAfter, } from "./client.js"; import { entityGuard } from "./readdir.js"; import { detectScope } from "./scope.js"; import { buildEntitySemanticJson } from "./semantic.js"; /** * Stream a keyed table's rows.jsonl a page of rows at a time, in key order, * the next page fetched only when the caller pulls it; any other file, a * window, and a relation with no row key (`fetchRowKey`) comes whole, as * `read` gives it. A table past `maxReadRows` rows or `maxReadBytes` bytes * throws EFBIG once that much is out, rather than refusing it whole: a stream * cannot know a table's size before it has paged through it, and a reader that * stops first (`head -c`, `grep -l`) never reaches it. Each page asks for one * row past what the row cap leaves and is sent only when it fits what the byte * cap leaves, so no page holds more than the caps allow; a page that does not * fit is asked again at half its rows, down to one row past the cap, so the * rows that fit still come out before the refusal. Mirrors Python's * `read_stream`. */ export async function* readStream(accessor, path, entry = null, parent = null, options = {}) { const spec = typeof path === 'string' ? PathSpec.fromStrPath(path) : path; const match = detectScope(spec); const schema = match.slots.schema ?? ''; const entity = match.slots.entity ?? ''; let pk = []; let columns = new Set(); if (match.kind === 'entity_rows' && options.limit == null && options.offset == null) { await entityGuard(accessor, match, spec.virtual); pk = await fetchRowKey(accessor, schema, entity); columns = await columnNames(accessor, schema, entity); } if (pk.length === 0) { yield await read(accessor, path, entry, parent, options); return; } const cfg = accessor.config; const encoder = new TextEncoder(); let rowsOut = 0; let bytesOut = 0; let size = cfg.defaultRowLimit; let after = null; for (;;) { const limit = Math.min(size, cfg.maxReadRows - rowsOut + 1); const [rows, last] = await fetchRowsAfter(accessor, schema, entity, pk, after, limit, columns, cfg.maxReadBytes - bytesOut); if (rows === null) { if (limit === 1) throw efbig(spec); size = Math.floor(limit / 2); continue; } after = last; const page = []; for (const row of rows) { const line = rowLine(row) + '\n'; rowsOut += 1; bytesOut += encoder.encode(line).byteLength; if (rowsOut > cfg.maxReadRows || bytesOut > cfg.maxReadBytes) { if (page.length > 0) yield encoder.encode(page.join('')); throw efbig(spec); } page.push(line); } if (page.length > 0) yield encoder.encode(page.join('')); if (rows.length < limit) return; } } /** * A relation's column names, which a byte-bounded page keeps its size marker * clear of. Mirrors Python's `_column_names`. */ async function columnNames(accessor, schema, entity) { return new Set((await fetchColumns(accessor, schema, entity)).map((column) => column.name)); } function entityKind(match) { return (match.slots.kind ?? '') === 'tables' ? 'table' : 'view'; } const readDatabaseJson = async (accessor) => jsonBytes(await buildDatabaseJson(accessor)); const readEntitySchema = async (accessor, match) => jsonBytes(await buildEntitySchemaJson(accessor, match.slots.schema ?? '', match.slots.entity ?? '', entityKind(match))); const readEntitySemantic = async (accessor, match) => jsonBytes(await buildEntitySemanticJson(accessor, match.slots.schema ?? '', match.slots.entity ?? '', entityKind(match))); const readEntityRows = (accessor, match, path, _entry, _parent, window) => readRows(accessor, match.slots.schema ?? '', match.slots.entity ?? '', path, window); const kitRead = makeRead(detectScope, { database_json: readDatabaseJson, entity_schema: readEntitySchema, entity_semantic: readEntitySemantic, }, { windowed: { entity_rows: readEntityRows }, parented: true }); export async function read(accessor, path, entry = null, parent = null, options = {}) { const spec = typeof path === 'string' ? PathSpec.fromStrPath(path) : path; return kitRead(accessor, spec, entry, parent, options); } /** * Render a relation's rows.jsonl, or the window `options` picks. The whole file * when neither limit nor offset is given, under the size guard: past * `maxReadRows` rows or `maxReadBytes` bytes it throws EFBIG, which a command * reports as `<cmd>: <path>: File too large` before moving on to its next * operand, as for an Airtable table past its cap. `path` is the rows.jsonl the * refusal names. Mirrors `read_rows` in `mirage/core/postgres/read.py`. */ export async function readRows(accessor, schema, entity, path, options = {}) { const cfg = accessor.config; const limit = options.limit ?? null; const offset = options.offset ?? null; let effectiveLimit; let effectiveOffset; const whole = limit === null && offset === null; if (whole) { const [rows, width] = await estimateSize(accessor, schema, entity); const widthEffective = Math.max(width, 1); if (rows > cfg.maxReadRows || rows * widthEffective > cfg.maxReadBytes) throw efbig(path); // The estimate only refuses; it never limits. It is planner statistics, // which lag the table (a bulk load before the next ANALYZE), so taking it // as the LIMIT returned fewer rows than exist, with nothing to say so. One // row past the ceiling keeps the read bounded and refuses a table the // estimate undercounted on the rows it really has. effectiveLimit = cfg.maxReadRows + 1; effectiveOffset = 0; } else { effectiveLimit = limit ?? cfg.defaultRowLimit; effectiveOffset = offset ?? 0; } const data = whole ? await fetchBoundedRows(accessor, schema, entity, { limit: effectiveLimit, maxBytes: cfg.maxReadBytes, }) : await fetchRows(accessor, schema, entity, { limit: effectiveLimit, offset: effectiveOffset, }); if (data === null || (whole && data.length > cfg.maxReadRows)) throw efbig(path); if (data.length === 0) return new Uint8Array(); const encoder = new TextEncoder(); const chunks = []; let size = 0; for (const row of data) { const line = encoder.encode(rowLine(row) + '\n'); size += line.byteLength; if (whole && size > cfg.maxReadBytes) throw efbig(path); chunks.push(line); } const body = new Uint8Array(size); let position = 0; for (const chunk of chunks) { body.set(chunk, position); position += chunk.byteLength; } return body; } /** * The first `n` rows of a relation's rows.jsonl, by a `LIMIT`. `maxReadRows` * is the most rows one read may return, so a count past it fetches one row * more than the ceiling and throws EFBIG when that row exists, as a whole read * of a relation that long does; a relation shorter than the ceiling comes back * whole. Mirrors `_first_rows` in `mirage/core/postgres/read.py`. */ async function firstRows(accessor, match, path, n) { const cap = accessor.config.maxReadRows; const data = await readRows(accessor, match.slots.schema ?? '', match.slots.entity ?? '', path, { limit: Math.min(n, cap + 1), }); let lines = 0; for (const byte of data) if (byte === 0x0a) lines += 1; if (lines > cap) throw efbig(path); return data; } /** * The last `n` rows of a relation's rows.jsonl, by a `COUNT(*)` and an * `OFFSET`. A suffix longer than `maxReadRows` throws EFBIG, as a whole read * of a relation that long does. Mirrors `_last_rows` in * `mirage/core/postgres/read.py`. */ async function lastRows(accessor, match, path, n) { const schema = match.slots.schema ?? ''; const entity = match.slots.entity ?? ''; const cap = accessor.config.maxReadRows; const pk = await fetchRowKey(accessor, schema, entity); if (pk.length > 0) { const [rows] = await fetchLastRows(accessor, schema, entity, pk, Math.min(n, cap + 1), await columnNames(accessor, schema, entity)); if (rows.length > cap) throw efbig(path); return lines(rows); } const total = await countRows(accessor, schema, entity); const limit = Math.min(n, total); if (limit > accessor.config.maxReadRows) throw efbig(path); if (limit === 0) return new Uint8Array(); return readRows(accessor, schema, entity, path, { limit, offset: total - limit }); } /** * How many rows a relation holds, one line each in its rows.jsonl, by a * `COUNT(*)`. Mirrors `_row_count` in `mirage/core/postgres/read.py`. */ function rowCount(accessor, match, _path) { return countRows(accessor, match.slots.schema ?? '', match.slots.entity ?? ''); } /** Rows as their rows.jsonl lines. Mirrors Python's `_lines`. */ function lines(rows) { return new TextEncoder().encode(rows.map((row) => rowLine(row) + '\n').join('')); } /** * A keyed table's last `n` rows, then each row whose key sorts past them, * asked for every `interval` seconds; null for a relation with no primary * key, whose rows have no end a new one lands at. The file lists rows in key * order, so a row lands at its end exactly when its key sorts past the last * one: each ask starts at the last key seen, and none is printed twice. A row * whose key sorts before it lands in the middle of the file, where `tail -f` * never looks. Mirrors Python's `_follow_rows`. */ async function followRows(accessor, match, path, n, interval) { const schema = match.slots.schema ?? ''; const entity = match.slots.entity ?? ''; const cap = accessor.config.maxReadRows; const pk = await fetchRowKey(accessor, schema, entity); if (pk.length === 0) return null; const columns = await columnNames(accessor, schema, entity); const [rows, after] = await fetchLastRows(accessor, schema, entity, pk, Math.max(Math.min(n, cap + 1), 1), columns); if (rows.length > cap) throw efbig(path); return followedRows(accessor, path, pk, columns, after, n > 0 ? rows.slice(-n) : [], interval); } /** * `rows`, then each row past `after` every `interval` seconds, as rows.jsonl * lines. One ask whose rows pass `maxReadBytes` throws EFBIG before any of * them is sent. An iterator rather than a generator, so closing ends a pull * waiting out its interval, as `followedDay` does. Mirrors Python's * `_followed_rows`. */ function followedRows(accessor, path, pk, columns, after, rows, interval) { const match = detectScope(path); const schema = match.slots.schema ?? ''; const entity = match.slots.entity ?? ''; const cfg = accessor.config; let pending = rows.length > 0 ? lines(rows) : null; let last = after; const stop = new AbortController(); const done = { value: undefined, done: true }; return { async next() { if (pending !== null) { const value = pending; pending = null; return { value, done: false }; } try { for (;;) { await sleep(interval * 1000, stop.signal); const [added, next] = await abortable(fetchRowsAfter(accessor, schema, entity, pk, last, cfg.defaultRowLimit, columns, cfg.maxReadBytes), stop.signal); if (added === null) throw efbig(path); last = next; if (added.length > 0) return { value: lines(added), done: false }; } } catch (err) { if (stop.signal.aborted) return done; throw err; } }, return() { stop.abort(); pending = null; return Promise.resolve(done); }, [Symbol.asyncIterator]() { return this; }, }; } export const firstLines = perKind(detectScope, { entity_rows: firstRows }, { guard: entityGuard }); export const lastLines = perKind(detectScope, { entity_rows: lastRows }, { guard: entityGuard }); export const lineCount = perKind(detectScope, { entity_rows: rowCount }, { guard: entityGuard }); export const follow = perKind(detectScope, { entity_rows: followRows }, { guard: entityGuard }); /** One row as rows.jsonl spells it. Mirrors `row_line` in `core/postgres/read.py`. */ export function rowLine(row) { return JSON.stringify(row, jsonReplacer); } function jsonReplacer(_key, value) { if (value instanceof Date) return value.toISOString(); if (typeof value === 'bigint') return value.toString(); if (value instanceof Uint8Array) return encodeBase64(value); return value; } //# sourceMappingURL=read.js.map