UNPKG

roam-research-mcp

Version:
259 lines (258 loc) • 11.6 kB
import { batchActions as roamBatchActions } from '@roam-research/roam-api-sdk'; import { generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js'; import { validateBatchActions, formatValidationErrors } from '../../shared/validation.js'; import { isRateLimitError, createRateLimitError } from '../../shared/errors.js'; import { ensurePagesExist } from '../../shared/page-validator.js'; import { withRateLimitRetry } from '../../shared/retry.js'; // Regex to match UID placeholders like {{uid:parent1}}, {{uid:section-a}}, etc. const UID_PLACEHOLDER_REGEX = /\{\{uid:([^}]+)\}\}/g; const DEFAULT_RATE_LIMIT_CONFIG = { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 60000, backoffMultiplier: 2 }; /** * Sleep for a specified number of milliseconds. */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } export class BatchOperations { constructor(graph, rateLimitConfig) { this.graph = graph; this.rateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG, ...rateLimitConfig }; } /** * Finds all unique UID placeholders in the actions and generates real UIDs for them. * Returns a map of placeholder name → generated UID. */ generateUidMap(actions) { const placeholders = new Set(); const actionsJson = JSON.stringify(actions); let match; // Reset regex lastIndex to ensure fresh matching UID_PLACEHOLDER_REGEX.lastIndex = 0; while ((match = UID_PLACEHOLDER_REGEX.exec(actionsJson)) !== null) { placeholders.add(match[1]); // The placeholder name (e.g., "parent1") } const uidMap = {}; for (const placeholder of placeholders) { uidMap[placeholder] = generateBlockUid(); } return uidMap; } /** * Replaces all {{uid:*}} placeholders in a string with their generated UIDs. */ replacePlaceholders(value, uidMap) { return value.replace(UID_PLACEHOLDER_REGEX, (_, name) => { return uidMap[name] || _; // Return original if not found (shouldn't happen) }); } /** * Recursively replaces placeholders in an object/array. */ replacePlaceholdersInObject(obj, uidMap) { if (typeof obj === 'string') { return this.replacePlaceholders(obj, uidMap); } if (Array.isArray(obj)) { return obj.map(item => this.replacePlaceholdersInObject(item, uidMap)); } if (obj && typeof obj === 'object') { const result = {}; for (const key of Object.keys(obj)) { result[key] = this.replacePlaceholdersInObject(obj[key], uidMap); } return result; } return obj; } /** * Executes the batch operation, retrying while Roam throttles us. * * This used to be a hand-rolled loop identical to the one in * `withRateLimitRetry`, which is precisely how `executeStagedBatch` came to * be written with no retry at all — the logic was private here, so the next * write path could not reuse it and simply went without. A half-written page * and no undo was the cost. * * On exhaustion the shared helper rethrows Roam's original error rather than * a synthesised one carrying `retryAfterMs`. `processBatch` never used that * field's value for anything the caller saw except the backoff hint, which * now comes from this instance's own config — a more honest source than * whatever happened to be stapled to an error object. */ async executeWithRetry(batchActions) { await withRateLimitRetry(() => roamBatchActions(this.graph, { actions: batchActions }), this.rateLimitConfig, (attempt, waitMs) => console.log(`[batch] Rate limited, retrying in ${waitMs}ms (attempt ${attempt}/${this.rateLimitConfig.maxRetries})`)); } async processBatch(actions) { // Step 0: Pre-validate all actions before any execution const validationResult = validateBatchActions(actions); if (!validationResult.valid) { return { success: false, error: { code: 'VALIDATION_ERROR', message: formatValidationErrors(validationResult.errors), details: validationResult.errors.length > 0 ? { action_index: validationResult.errors[0].actionIndex, field: validationResult.errors[0].field, expected: validationResult.errors[0].expected, received: validationResult.errors[0].received } : undefined }, validation_passed: false, actions_attempted: 0 }; } // Step 0.5: Validate parent pages exist (auto-creates daily pages) // This uses batched queries and caching to minimize API calls try { const pageValidation = await ensurePagesExist(this.graph, actions, { maxRetries: this.rateLimitConfig.maxRetries, initialDelayMs: this.rateLimitConfig.initialDelayMs, maxDelayMs: this.rateLimitConfig.maxDelayMs, backoffMultiplier: this.rateLimitConfig.backoffMultiplier }); if (pageValidation.created > 0) { console.log(`[batch] Auto-created ${pageValidation.created} daily page(s), checked ${pageValidation.checked}, cached ${pageValidation.cached}`); } } catch (error) { // Page validation failed - return structured error const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: { code: 'PAGE_NOT_FOUND', message: errorMessage, recovery: { suggestion: 'Create the missing page(s) first with roam_create_page, or verify the parent-uid is correct' } }, validation_passed: true, // Syntax validation passed, page validation failed actions_attempted: 0 }; } // Step 1: Generate UIDs for all placeholders const uidMap = this.generateUidMap(actions); const hasPlaceholders = Object.keys(uidMap).length > 0; // Step 2: Replace placeholders with real UIDs const processedActions = hasPlaceholders ? this.replacePlaceholdersInObject(actions, uidMap) : actions; // Step 3: Convert to Roam batch actions format const batchActions = processedActions.map((action) => { const { action: actionType, ...rest } = action; const roamAction = { action: actionType }; if (rest.location) { roamAction.location = { 'parent-uid': rest.location['parent-uid'], order: rest.location.order, }; } const block = {}; if (rest.string) { // Parse markdown heading syntax (e.g., "### Description" -> heading: 3, string: "Description") const { heading_level, content } = parseMarkdownHeadingLevel(rest.string); block.string = heading_level > 0 ? content : rest.string; // Use parsed heading level if not explicitly overridden if (heading_level > 0 && rest.heading === undefined) { block.heading = heading_level; } } if (rest.uid) block.uid = rest.uid; if (rest.open !== undefined) block.open = rest.open; // Explicit heading parameter takes precedence over markdown syntax if (rest.heading !== undefined && rest.heading !== null && rest.heading !== 0) { block.heading = rest.heading; } if (rest['text-align']) block['text-align'] = rest['text-align']; if (rest['children-view-type']) block['children-view-type'] = rest['children-view-type']; if (Object.keys(block).length > 0) { roamAction.block = block; } return roamAction; }); try { await this.executeWithRetry(batchActions); // SUCCESS: Return uid_map only on success const result = { success: true, validation_passed: true, actions_attempted: batchActions.length }; if (hasPlaceholders) { result.uid_map = uidMap; } return result; } catch (error) { // FAILURE: Do NOT return uid_map - blocks don't exist const errorMessage = error instanceof Error ? error.message : String(error); // Check for parent entity error - retry once after delay (Roam eventual consistency) if (errorMessage.includes("Parent entity doesn't exist")) { console.log('[batch] Parent entity not found, retrying after 400ms...'); await sleep(400); try { await this.executeWithRetry(batchActions); // SUCCESS on retry const result = { success: true, validation_passed: true, actions_attempted: batchActions.length }; if (hasPlaceholders) { result.uid_map = uidMap; } return result; } catch (retryError) { // Still failed after retry const retryErrorMessage = retryError instanceof Error ? retryError.message : String(retryError); return { success: false, error: { code: 'PARENT_ENTITY_NOT_FOUND', message: `${retryErrorMessage} (retried once after 400ms delay)`, recovery: { suggestion: 'Verify the parent block/page UID exists and is spelled correctly' } }, validation_passed: true, actions_attempted: batchActions.length }; } } // Check if it's a rate limit error if (isRateLimitError(error)) { return { success: false, error: createRateLimitError(this.rateLimitConfig.maxDelayMs), validation_passed: true, actions_attempted: batchActions.length // No uid_map - nothing was committed }; } return { success: false, error: { code: 'TRANSACTION_FAILED', message: errorMessage, recovery: { suggestion: 'Check the error message and retry with corrected actions' } }, validation_passed: true, actions_attempted: batchActions.length // No uid_map - nothing was committed (or we can't verify what was) }; } } }