UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

153 lines • 6.95 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 { FlagView } from "../../../../spec/flag_view.js"; import { NotionAPIError } from "../../../../../core/notion/client.js"; import { HintedAPIError, sourceHint } from "../failure.js"; import { getDataSource, getDatabase, queryDataSourcePage, } from "../../../../../core/notion/pages.js"; import { IOResult } from "../../../../../io/types.js"; import { firstText, notionTransport, parseJsonText, prettyJson, propertyCell, usageError, } from "../util.js"; import { asObject, stringOf } from "../../../../../utils/records.js"; import { compareCodePoints } from "../../../../../utils/sort.js"; const ENC = new TextEncoder(); const DEC = new TextDecoder(); const DEFAULT_LIMIT = 25; // clap's refusal of a --limit under 1 (ntn 0.21.9), the value as typed. const LIMIT_REFUSAL = "error: invalid value '{}' for '--limit <LIMIT>': limit must be at " + "least 1\n\nFor more information, try '--help'."; const DIRECTIONS = { asc: 'ascending', ascending: 'ascending', desc: 'descending', descending: 'descending', }; // A data source operand may be an id or a full Notion URL. function dataSourceRef(operand) { const tail = operand.split('/').pop() ?? operand; return tail.split('?')[0] ?? tail; } function parseSort(spec) { const at = spec.lastIndexOf(' '); if (at > 0) { const tail = spec.slice(at + 1).toLowerCase(); const direction = DIRECTIONS[tail]; if (direction !== undefined) return { property: spec.slice(0, at), direction }; } return { property: spec, direction: 'ascending' }; } // A database id is accepted in the same slot as a data source id, so a miss on // the data source endpoint is not an error until the database endpoint misses // too. async function resolveSource(transport, ref) { let first; try { return await getDataSource(transport, ref); } catch (err) { if (!(err instanceof NotionAPIError) || err.status !== 404) throw err; first = err; } // The database endpoint is a fallback, so its own 404 is not the answer to // report: upstream names the *data source* the operand failed to be and adds // one hint covering both, where reporting the second miss would tell the user // their data source id is not a database id, which they never claimed it was. let database; try { database = await getDatabase(transport, ref); } catch (err) { if (!(err instanceof NotionAPIError) || err.status !== 404) throw err; throw new HintedAPIError(first, sourceHint(ref)); } const stubs = Array.isArray(database.data_sources) ? database.data_sources : []; const head = stubs[0]; if (head === undefined) throw new Error(`database ${ref} has no data sources`); return getDataSource(transport, stringOf(asObject(head), 'id')); } // The query filter comes from --filter or --filter-file, and the file is read // through the op dispatcher the way himalaya reads --attach: an account CLI may // read a workspace file the user named on the line, it just must not treat a // mount as a second view of its own account's data. async function filterBody(fl, vfs) { const inline = fl.asStr('filter'); if (inline !== undefined && inline !== '') return parseJsonText(inline, '--filter'); const source = fl.asPath('filter_file'); if (source === undefined) return null; if (vfs === undefined) throw new Error('--filter-file needs a workspace to read files from'); return parseJsonText(DEC.decode(await vfs.read(source)), '--filter-file'); } export async function query(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const body = {}; let ref; try { // clap checks a value as it parses it, ahead of the missing operand. const limit = fl.asInt('limit', DEFAULT_LIMIT); if (limit < 1) throw new Error(LIMIT_REFUSAL.replace('{}', fl.asStr('limit', String(limit)))); ref = dataSourceRef(firstText(texts, 'data source id')); body.page_size = limit; const cursor = fl.asStr('start_cursor'); if (cursor !== undefined && cursor !== '') body.start_cursor = cursor; const sorts = fl.asList('sort').map(parseSort); if (sorts.length > 0) body.sorts = sorts; const chosen = await filterBody(fl, ctx.vfs); if (chosen !== null) body.filter = chosen; } catch (err) { return usageError(err); } const transport = notionTransport(accessor, ctx.flags); const dataSource = await resolveSource(transport, ref); const result = await queryDataSourcePage(transport, stringOf(dataSource, 'id'), body); if (fl.asBool('json')) return [prettyJson(result), new IOResult()]; // Columns are the property names the returned rows actually carry, in // alphabetical order, not the data source's whole schema. Upstream derives // them from the page objects it got back, so a result set that does not // cover the schema prints narrower: a row created from Markdown alone holds // only its title column, and on its own it prints as `<id>\t<title>` rather // than as one title among seven blanks. const rows = Array.isArray(result.results) ? result.results : []; const named = new Set(); for (const row of rows) { for (const name of Object.keys(asObject(asObject(row).properties))) named.add(name); } const columns = [...named].sort(compareCodePoints); let out = ''; for (const row of rows) { const record = asObject(row); const props = asObject(record.properties); const cells = columns.map((name) => propertyCell(props[name])); out += `${[stringOf(record, 'id'), ...cells].join('\t')}\n`; } const stdout = ENC.encode(out); const cursor = result.next_cursor; if (result.has_more === true && typeof cursor === 'string' && cursor !== '') { const notice = `\nMore results available. Use --start-cursor ${cursor} to continue.\n`; return [stdout, new IOResult({ stderr: ENC.encode(notice) })]; } return [stdout, new IOResult()]; } //# sourceMappingURL=query.js.map