UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

192 lines • 8.39 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 { jsonBytes } from "../../../../core/render/json.js"; import { createComment, createRecords, deleteRecords, listTables, updateRecords, } from "../../../../core/airtable/client.js"; import { COMPUTED_TYPES, LINE_KEYS } from "../../../../core/airtable/constants.js"; import { asRow, asRows, deletionsJsonl, isRow, normalizeComment, recordsJsonl, } from "../../../../core/airtable/normalize.js"; import { IOResult } from "../../../../io/types.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { UsageError } from "../../../errors.js"; import { findTable, jsonObject, noOperands, oneOperand, optionalOperand, run, scopedBase, stdinText, } from "./util.js"; const ENC = new TextEncoder(); /** * Stdin's JSONL, one normalized record per line, validated whole. A line is * the shape records.jsonl holds, so a mount read piped through jq * round-trips. Every line is checked before anything is sent: a bad line 15 * must not leave ten records already written. A blank line is skipped but * still counted, so a refusal names the line an editor shows. */ function recordLines(text, need) { const rows = []; text.split('\n').forEach((line, index) => { if (/^[ \t\r]*$/.test(line)) return; const where = `stdin line ${String(index + 1)}`; let row; try { row = JSON.parse(line); } catch { throw new UsageError(`${where}: not valid JSON`); } if (!isRow(row)) throw new UsageError(`${where}: not a JSON object`); const unknown = Object.keys(row) .filter((key) => !LINE_KEYS.has(key)) .sort(compareCodePoints)[0]; if (unknown !== undefined) { throw new UsageError(`${where}: unknown key ${JSON.stringify(unknown)}`); } if ('fields' in row && !isRow(row.fields)) { throw new UsageError(`${where}: "fields" must be an object`); } if (need.id && !('record_id' in row)) { throw new UsageError(`${where}: "record_id" is required`); } if (need.id && typeof row.record_id !== 'string') { throw new UsageError(`${where}: "record_id" must be a string`); } if (need.fields && !('fields' in row)) { throw new UsageError(`${where}: "fields" is required`); } rows.push(row); }); return rows; } /** The cells with every computed field of the table taken out. */ async function writable(accessor, baseId, table, cells) { const found = findTable(await listTables(accessor, baseId), table); const fields = asRows(asRow(found).fields); const computed = new Set(fields.filter((f) => COMPUTED_TYPES.has(String(f.type))).map((f) => f.name)); return cells.map((c) => Object.fromEntries(Object.entries(c).filter(([k]) => !computed.has(k)))); } /** * Everything the batches wrote, and the failure that stopped them. Batches * land one request at a time, so a failure part way leaves the earlier ones * written. They are printed anyway, with the error on stderr and exit 1, so * the line shows what reached the base. A failure before anything landed * throws, for the executor to render as `<prog>: <error>`; a partial one * carries no prefix, since only the executor knows the head word the CLI was * installed under, and the error names its call. */ async function landed(batches, render) { const done = []; try { for await (const batch of batches) done.push(...batch); } catch (err) { if (done.length === 0) throw err; const message = err instanceof Error ? err.message : String(err); return [render(done), new IOResult({ exitCode: 1, stderr: ENC.encode(`${message}\n`) })]; } return [render(done), new IOResult()]; } async function recordCreateBody(accessor, texts, ctx, fl) { noOperands(texts); const fields = fl.asStr('fields'); const table = fl.asStr('table') ?? ''; let cells; let piped = false; if (fields !== undefined) { cells = [jsonObject('--fields', fields)]; } else if (ctx.stdin !== null) { const rows = recordLines(await stdinText(ctx.stdin), { id: false, fields: true }); cells = rows.map((row) => row.fields); piped = rows.length > 0; } else { throw new UsageError('--fields or records on stdin are required'); } const baseId = scopedBase(accessor.config, fl.asStr('base') ?? ''); if (piped) cells = await writable(accessor, baseId, table, cells); const batches = createRecords(accessor, baseId, table, cells, { typecast: fl.asBool('typecast'), }); return landed(batches, recordsJsonl); } async function recordUpdateBody(accessor, texts, ctx, fl) { const recordId = optionalOperand(texts); const fields = fl.asStr('fields'); const table = fl.asStr('table') ?? ''; if (recordId !== null && fields === undefined) { throw new UsageError('--fields is required with RECORD'); } if (recordId === null && fields !== undefined) { throw new UsageError('RECORD is required with --fields'); } let ids; let cells; let piped = false; if (recordId !== null && fields !== undefined) { ids = [recordId]; cells = [jsonObject('--fields', fields)]; } else if (ctx.stdin !== null) { const rows = recordLines(await stdinText(ctx.stdin), { id: true, fields: true }); ids = rows.map((row) => String(row.record_id)); cells = rows.map((row) => row.fields); piped = rows.length > 0; } else { throw new UsageError('RECORD --fields or records on stdin are required'); } const baseId = scopedBase(accessor.config, fl.asStr('base') ?? ''); if (piped) cells = await writable(accessor, baseId, table, cells); const updates = ids.map((id, i) => [id, cells[i] ?? {}]); const batches = updateRecords(accessor, baseId, table, updates, { typecast: fl.asBool('typecast'), }); return landed(batches, recordsJsonl); } async function recordDeleteBody(accessor, texts, ctx, fl) { let recordIds; if (texts.length > 0) { recordIds = [...texts]; } else if (ctx.stdin !== null) { const rows = recordLines(await stdinText(ctx.stdin), { id: true, fields: false }); recordIds = rows.map((row) => String(row.record_id)); } else { throw new UsageError('RECORD or records on stdin are required'); } const baseId = scopedBase(accessor.config, fl.asStr('base') ?? ''); const batches = deleteRecords(accessor, baseId, fl.asStr('table') ?? '', recordIds); return landed(batches, deletionsJsonl); } async function commentAddBody(accessor, texts, ctx, fl) { const recordId = oneOperand(texts, 'RECORD'); let text = fl.asStr('text'); if (text === undefined) { if (ctx.stdin === null) throw new UsageError('--text or text on stdin is required'); const piped = await stdinText(ctx.stdin); text = piped.endsWith('\n') ? piped.slice(0, -1) : piped; } if (text === '') throw new UsageError('the comment text is empty'); const baseId = scopedBase(accessor.config, fl.asStr('base') ?? ''); const comment = await createComment(accessor, baseId, fl.asStr('table') ?? '', recordId, text); return [jsonBytes(normalizeComment(comment)), new IOResult()]; } export const recordCreate = run(recordCreateBody); export const recordUpdate = run(recordUpdateBody); export const recordDelete = run(recordDeleteBody); export const commentAdd = run(commentAddBody); //# sourceMappingURL=writes.js.map