UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

210 lines • 9.9 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 { apiRequest } from "../api/client.js"; import { cursorItems, cursorPages, offsetCursor } from "../api/paginate.js"; import { MAX_BATCH, META_KEY, PAGE_SIZE, WEBHOOK_PAGE_SIZE } from "./constants.js"; import { AirtableApiError, errorParts } from "./errors.js"; import { asRow, asRows } from "./normalize.js"; export const BASES = { itemsKey: 'bases', nextCursor: offsetCursor }; export const RECORDS = { itemsKey: 'records', nextCursor: offsetCursor }; export const COMMENTS = { itemsKey: 'comments', nextCursor: offsetCursor }; function errorOf(call) { return (response, text) => { const [kind, message] = errorParts(text); const detail = [kind, message].filter((part) => part !== null && part !== ''); const suffix = detail.length > 0 ? `: ${detail.join(': ')}` : ''; return new AirtableApiError(`Airtable API error (${call}): HTTP ${String(response.status)}${suffix}`, response.status, kind); }; } // A 429 means either "slow down" (RATE_LIMIT_REACHED) or "this workspace // spent its monthly calls"; waiting only helps the first. function retryable(_status, text) { return errorParts(text)[0] !== 'PUBLIC_API_BILLING_LIMIT_EXCEEDED'; } // After a 429 Airtable refuses every request to the base for 30 seconds, so // a retry sooner than that is spent inside the penalty; 502 and 503 are // documented as safe to retry with backoff. export const RETRY = { statuses: new Set([429, 502, 503]), maxRetries: 2, maxBackoff: 30, delaySource: 'header', retryable, minDelays: { 429: 30 }, }; // A write retries only the 429, which Airtable answers before it applies // anything. A 502 or 503 may come back after the records landed, and a // retried create would make them twice. export const WRITE_RETRY = { ...RETRY, statuses: new Set([429]) }; async function request(accessor, method, path, paceKey, options = {}) { await accessor.limiter.acquire(paceKey); const url = `${accessor.baseUrl}${path}`; const query = options.query ?? ''; const headers = { Authorization: `Bearer ${accessor.config.token}` }; if (options.json !== undefined) headers['Content-Type'] = 'application/json'; return apiRequest(method, query === '' ? url : `${url}?${query}`, { errorOf: errorOf(`${method} ${path}`), headers, params: options.params, json: options.json, retry: options.retry ?? RETRY, fetchFn: accessor.fetchFn, timeout: accessor.config.timeout, }); } function get(accessor, path, params, paceKey) { return request(accessor, 'GET', path, paceKey, { params }); } const segment = encodeURIComponent; function tablePath(baseId, table) { return `/${segment(baseId)}/${segment(table)}`; } function recordPath(baseId, table, recordId) { return `${tablePath(baseId, table)}/${segment(recordId)}`; } function batches(items) { const out = []; for (let start = 0; start < items.length; start += MAX_BATCH) { out.push(items.slice(start, start + MAX_BATCH)); } return out; } async function page(accessor, paceKey, path, params, cursor) { return asRow(await get(accessor, path, cursor !== null ? { ...params, offset: cursor } : params, paceKey)); } /** Every base the token reaches, narrowed to the configured `baseIds`. */ export async function listBases(accessor) { const bases = await cursorItems((cursor) => page(accessor, META_KEY, '/meta/bases', {}, cursor), undefined, BASES); const wanted = accessor.baseIds; return asRows(bases).filter((base) => wanted === null || wanted.includes(String(base.id))); } /** A base's schema: its tables with their fields and views. */ export async function listTables(accessor, baseId) { const data = await get(accessor, `/meta/bases/${segment(baseId)}/tables`, undefined, baseId); return asRows(asRow(data).tables); } /** * A table's records in the API's order, or a view's. Without a view * Airtable calls the order arbitrary; it is the order the table hands out, * stable between calls, and the only one a `maxRecords` prefix agrees with. * The table and the view are named by id or by name; `formula` is * `filterByFormula`, listing only the records it is true for; `maxRecords` * stops after that many records, both on the wire and in the collector. */ /** A base's webhooks, each with `cursorForNextPayload`. Mirrors Python `list_webhooks`. */ export async function listWebhooks(accessor, baseId) { const data = await get(accessor, `/bases/${segment(baseId)}/webhooks`, undefined, baseId); return asRows(asRow(data).webhooks); } /** * One page of a webhook's payloads from `cursor` (null for the oldest Airtable * still keeps): `payloads`, the `cursor` after them, and `mightHaveMore`. * Mirrors Python `webhook_payloads`. */ export async function webhookPayloads(accessor, baseId, webhookId, cursor) { const params = { limit: WEBHOOK_PAGE_SIZE }; if (cursor !== null) params.cursor = cursor; const path = `/bases/${segment(baseId)}/webhooks/${segment(webhookId)}`; return asRow(await get(accessor, `${path}/payloads`, params, baseId)); } export async function listRecords(accessor, baseId, tableId, options = {}) { const params = recordParams(options); const path = tablePath(baseId, tableId); const found = await cursorItems((cursor) => page(accessor, baseId, path, params, cursor), options.maxRecords, RECORDS); return asRows(found); } /** * A table's records as `listRecords` lists them, one page at a time, the * next page fetched only when the caller pulls it. Mirrors Python's * `record_pages`. */ export async function* recordPages(accessor, baseId, tableId, options = {}) { const params = recordParams(options); const path = tablePath(baseId, tableId); for await (const found of cursorPages((cursor) => page(accessor, baseId, path, params, cursor), RECORDS)) yield asRows(found); } function recordParams(options) { const params = { pageSize: PAGE_SIZE }; if (options.view !== undefined) params.view = options.view; if (options.formula !== undefined) params.filterByFormula = options.formula; if (options.maxRecords !== undefined) params.maxRecords = options.maxRecords; return params; } /** One record by id; the table is named by id or by name. */ export async function getRecord(accessor, baseId, tableId, recordId) { return asRow(await get(accessor, recordPath(baseId, tableId, recordId), undefined, baseId)); } async function* write(accessor, method, baseId, tableId, rows, typecast) { const path = tablePath(baseId, tableId); for (const batch of batches(rows)) { const body = { records: batch }; if (typecast === true) body.typecast = true; const data = await request(accessor, method, path, baseId, { json: body, retry: WRITE_RETRY }); yield asRows(asRow(data).records); } } /** * Create records ten to a request, yielding each request's records. Each * entry is a new record's cell map, keyed by field name; `typecast` lets * Airtable convert string values to the field types. A request that fails * throws after every earlier one landed, so the caller holds exactly what * was written. */ export function createRecords(accessor, baseId, tableId, cells, options = {}) { const rows = cells.map((fields) => ({ fields })); return write(accessor, 'POST', baseId, tableId, rows, options.typecast); } /** * Patch records ten to a request, yielding each request's records. A * PATCH, never a PUT: a cell the update leaves out keeps its value. Each * entry is a record id and the cells to change, keyed by field name. */ export function updateRecords(accessor, baseId, tableId, updates, options = {}) { const rows = updates.map(([id, fields]) => ({ id, fields })); return write(accessor, 'PATCH', baseId, tableId, rows, options.typecast); } /** * Delete records ten to a request, yielding each request's answer. The ids * ride the query string as `records[]`, which is the only place the * endpoint reads them from. */ export async function* deleteRecords(accessor, baseId, tableId, recordIds) { const path = tablePath(baseId, tableId); for (const batch of batches(recordIds)) { const query = new URLSearchParams(batch.map((id) => ['records[]', id])).toString(); const data = await request(accessor, 'DELETE', path, baseId, { query, retry: WRITE_RETRY }); yield asRows(asRow(data).records); } } /** A record's comments in the API's order, newest first. */ export async function listComments(accessor, baseId, tableId, recordId) { const path = `${recordPath(baseId, tableId, recordId)}/comments`; const found = await cursorItems((cursor) => page(accessor, baseId, path, { pageSize: PAGE_SIZE }, cursor), undefined, COMMENTS); return asRows(found); } /** Comment on a record as the token's user. */ export async function createComment(accessor, baseId, tableId, recordId, text) { return asRow(await request(accessor, 'POST', `${recordPath(baseId, tableId, recordId)}/comments`, baseId, { json: { text }, retry: WRITE_RETRY, })); } //# sourceMappingURL=client.js.map