@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
153 lines • 6.95 kB
JavaScript
// ========= 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