UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

235 lines • 11.4 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 { TEXT_TYPES } from "../../accessor/postgres.js"; import { concat } from "../../utils/bytes.js"; import { fetchBoundedQuery, fetchRowKey, keyOrder, qualified, quoteIdent } from "./client.js"; import { rowLine } from "./read.js"; import { detectScope } from "./scope.js"; // Types whose `::text` is the value exactly as a rows.jsonl line spells it, // so a LIKE over the cast finds every row whose line holds the text inside // that value. A date or time type's row keeps that text. Mirrors // `_SAME_TEXT_TYPES` in `mirage/core/postgres/search.py`. const SAME_TEXT_TYPES = new Set([ 'uuid', 'int2', 'int4', 'int8', 'bool', ...Object.keys(TEXT_TYPES), ]); // The strings among them, which can hold a control character the line spells // as an escape (`\n`, `\u0001`), so the text can match the escape's letters in // a row whose value never holds them: such rows are candidates too. An enum's // label is one as well. const STRING_TYPES = new Set(['text', 'varchar', 'name']); // Array elements whose `::text` is each element as the line spells it. const SAME_TEXT_ELEMENTS = new Set([ ...STRING_TYPES, 'uuid', 'int2', 'int4', 'int8', ...Object.keys(TEXT_TYPES), ]); // Every character a value of these types spells in a line on either host (a // float's exponent, a numeric's NaN or Infinity), folded to lowercase. A text // holding any other character is never inside such a value, so the column is // left out of the search. const NUMBER = '0123456789.+-e'; const SPELLED_WITH = { float4: NUMBER, float8: NUMBER, numeric: `${NUMBER}nainfty`, }; // A value of each date and time type and of an array of each, which a driver // that reads them as Postgres text (`readAsText`) answers as text. Only then // does a LIKE over their cast see what the line spells: a browser mount takes // any driver, and one that parses them (into a `Date`) leaves them to the scan. const TEXT_PROBE = "SELECT 'epoch'::date AS d, 'epoch'::timestamp AS ts, 'epoch'::timestamptz AS tz, " + "'allballs'::time AS t, 'allballs'::timetz AS ttz, '0'::interval AS i, " + "'{epoch}'::date[] AS da, '{epoch}'::timestamp[] AS tsa, " + "'{epoch}'::timestamptz[] AS tza, '{allballs}'::time[] AS ta, " + "'{allballs}'::timetz[] AS ttza, '{0}'::interval[] AS ia"; // What a line spells around and between values: a text holding one can match // where no single value holds it (`:4` after a key). const STRUCTURAL = new Set(['"', '\\', ':', ',', '{', '}']); // How a NULL spells in the line; no LIKE over a NULL ever matches it. const NULL = 'null'; // Relations whose rows a plain read takes in ctid order: a table or a // materialized view with no child. Any other (a view, a parent appending its // children's rows) is numbered in the order a plain read returns it. const HEAP_KINDS = new Set(['r', 'm']); const RELATION = 'SELECT c.relkind::text AS relkind, c.relhassubclass FROM pg_class c ' + 'JOIN pg_namespace n ON n.oid = c.relnamespace ' + 'WHERE n.nspname = $1 AND c.relname = $2'; // Each column's type, a domain's base type in its place, with its category // (E an enum, A an array) and an array's element type. const COLUMNS = 'SELECT a.attname AS name, COALESCE(b.typname, t.typname) AS type, ' + 'COALESCE(b.typcategory, t.typcategory)::text AS category, ' + 'e.typname AS element FROM pg_attribute a ' + 'JOIN pg_class c ON c.oid = a.attrelid ' + 'JOIN pg_namespace n ON n.oid = c.relnamespace ' + 'JOIN pg_type t ON t.oid = a.atttypid ' + 'LEFT JOIN pg_type b ON b.oid = t.typbasetype ' + 'LEFT JOIN pg_type e ON e.oid = COALESCE(b.typelem, t.typelem) ' + 'WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 ' + 'AND NOT a.attisdropped ORDER BY a.attnum'; // Escape LIKE/ILIKE wildcards so the text matches as a literal: Postgres LIKE // treats % and _ as wildcards and \ as the default escape char, but grep's // text has no such meaning (`user_id` must not match `userXid`). function escapeLike(text) { return text.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); } /** * How a LIKE covers one column: `like` over its cast, `string` over its cast * plus its rows holding a control character, `skip` when no value of it can * spell `text`, null when none of these sees every match. A `char(n)` pads its * value and its cast strips the padding, so only a text without a space is * covered. A jsonb value lines up with its cast except for a number the line * may spell another way (a large one in exponent form), so a text that could * lie inside a number, or that holds a bracket next to one, is not. An * array's line brackets where its cast braces. Mirrors `_column_search` in * `mirage/core/postgres/search.py`. */ function columnSearch(column, text) { const folded = [...new Set(text.toLowerCase())]; const bracketed = text.includes('[') || text.includes(']'); if (STRING_TYPES.has(column.type) || column.category === 'E') return 'string'; if (SAME_TEXT_TYPES.has(column.type)) return 'like'; if (column.type === 'bpchar') return text.includes(' ') ? null : 'string'; if (column.type === 'jsonb') { return bracketed || folded.every((ch) => NUMBER.includes(ch)) ? null : 'like'; } if (column.category === 'A') { if (column.element === null || !SAME_TEXT_ELEMENTS.has(column.element) || bracketed) { return null; } return STRING_TYPES.has(column.element) ? 'string' : 'like'; } const spelled = SPELLED_WITH[column.type]; if (spelled !== undefined && !folded.every((ch) => spelled.includes(ch))) return 'skip'; return null; } /** * Each searched column and how, or null when a LIKE over the columns cannot * find every line a scan would. A LIKE per column sees only values: a text * that can match a key (every row holds every key), the text between values, * a NULL's `null` or a value the line spells differently from its cast would * be found by the scan and missed by the query. Mirrors `_searches` in * `mirage/core/postgres/search.py`. */ function searches(columns, text, ignoreCase) { for (const ch of text) { if (STRUCTURAL.has(ch) || (ch.codePointAt(0) ?? 0) < 0x20) return null; } const folded = ignoreCase ? text.toLowerCase() : text; if (NULL.includes(folded)) return null; const searched = []; for (const column of columns) { if ((ignoreCase ? column.name.toLowerCase() : column.name).includes(folded)) return null; const how = columnSearch(column, text); if (how === null) return null; if (how !== 'skip') searched.push([column.name, how]); } return searched; } /** * The lines of a relation's rows.jsonl that may hold `text`. A LIKE (ILIKE * under -i) over every column that can spell `text` picks the rows whose value * holds it, plus any row with a control character the line spells as an * escape; grep matches each line itself. The rows come in the plain read's * order: a keyed relation's in key order; a keyless table's in ctid order, the * order the plain read scans the heap in, whatever plan the filter gets (an * index or a parallel scan returns them otherwise); any other relation's * numbered as a plain read returns them before the filter. Null for any other * file, when the columns or the text keep a LIKE from seeing every match * (`searches`), when a date or time column is searched and the driver does not * read those types as text (`TEXT_PROBE`), or past `maxReadRows` rows or * `maxReadBytes` bytes, where reading the file refuses it as too large. * Mirrors `lines_containing` in `mirage/core/postgres/search.py`. */ export async function linesContaining(accessor, path, text, ignoreCase) { const match = detectScope(path); if (match.kind !== 'entity_rows') return null; const schema = match.slots.schema ?? ''; const entity = match.slots.entity ?? ''; const { maxReadRows, maxReadBytes } = accessor.config; const relation = (await accessor.store.query(RELATION, [ schema, entity, ])).rows; const [only] = relation; if (relation.length !== 1 || only === undefined) return null; const columns = (await accessor.store.query(COLUMNS, [schema, entity])).rows; if (columns.length === 0) return null; const searched = searches(columns, text, ignoreCase); if (searched === null) return null; if (searched.length === 0) return new Uint8Array(0); if (columns.some((c) => Object.hasOwn(TEXT_TYPES, c.type) || (c.category === 'A' && c.element !== null && Object.hasOwn(TEXT_TYPES, c.element))) && !Object.values((await accessor.store.query(TEXT_PROBE)).rows[0] ?? {}).every((value) => typeof value === 'string' || (Array.isArray(value) && value.every((element) => typeof element === 'string')))) { return null; } const op = ignoreCase ? 'ILIKE' : 'LIKE'; const clauses = []; for (const [name, how] of searched) { clauses.push(`${quoteIdent(name)}::text ${op} $1`); if (how === 'string') clauses.push(`${quoteIdent(name)}::text ~ '[[:cntrl:]]'`); } const names = new Set(columns.map((c) => c.name)); const source = qualified(schema, entity); const where = clauses.join(' OR '); let marker = '__mirage_row'; while (names.has(marker)) marker += '_'; const numbered = quoteIdent(marker); const pk = await fetchRowKey(accessor, schema, entity); const sql = pk.length > 0 ? `SELECT * FROM ${source} WHERE ${where}${keyOrder(pk)} LIMIT $2` : HEAP_KINDS.has(only.relkind) && !only.relhassubclass ? `SELECT * FROM ${source} WHERE ${where} ORDER BY ctid LIMIT $2` : `SELECT * FROM (SELECT row_number() OVER () AS ${numbered}, r.* FROM ${source} r) s ` + `WHERE ${where} ORDER BY ${numbered} LIMIT $2`; const rows = await fetchBoundedQuery(accessor, sql, [`%${escapeLike(text)}%`, maxReadRows + 1], new Set([...names, marker]), maxReadBytes); if (rows === null || rows.length > maxReadRows) return null; const encoder = new TextEncoder(); const chunks = []; let size = 0; for (const row of rows) { const line = encoder.encode(rowLine(Object.fromEntries(Object.entries(row).filter(([key]) => key !== marker))) + '\n'); size += line.length; if (size > maxReadBytes) return null; chunks.push(line); } return concat(chunks); } //# sourceMappingURL=search.js.map