/** * Google Sheets Integration * * Provides REST API endpoints optimized for Google Apps Script to enable * two-way sync between Brainy and Google Sheets. * * Features: * - Custom function support (=BRAINY_QUERY(), =BRAINY_ADD()) * - Real-time updates via SSE subscription * - Batch operations for performance * - Simple authentication (API key or Bearer token) * * Zero external dependencies - works in all environments. */ import { IntegrationBase, HTTPIntegration } from '../core/IntegrationBase.js' import { IntegrationConfig } from '../core/types.js' import { Entity, FindParams } from '../../types/brainy.types.js' import { NounType, VerbType } from '../../types/graphTypes.js' /** * Google Sheets integration configuration */ export interface GoogleSheetsConfig extends IntegrationConfig { /** Base path for API routes (default: '/sheets') */ basePath?: string /** Port to listen on (only for standalone) */ port?: number /** Maximum results per query (default: 1000) */ maxResults?: number /** Default query limit (default: 100) */ defaultLimit?: number /** Allow write operations (default: true) */ allowWrite?: boolean /** CORS origins to allow (default: Google Sheets) */ allowedOrigins?: string[] } /** * Sheets API request */ interface SheetsRequest { method: string path: string query: Record body?: any headers: Record } /** * Sheets API response */ interface SheetsResponse { status: number headers: Record body: any } /** * Google Sheets Integration * * Provides a simple REST API optimized for Google Apps Script custom functions. * * Endpoints: * - GET /sheets/query - Query entities (for =BRAINY_QUERY()) * - GET /sheets/entity/:id - Get single entity (for =BRAINY_GET()) * - GET /sheets/similar - Semantic search (for =BRAINY_SIMILAR()) * - GET /sheets/relations - Get relationships (for =BRAINY_RELATIONS()) * - POST /sheets/add - Add entity (for sidebar) * - POST /sheets/batch - Batch operations (for range sync) * - GET /sheets/schema - Get type schema (for sidebar dropdown) * - GET /sheets/stream - SSE for real-time updates * * Response Format (optimized for Sheets): * ```json * { * "headers": ["Id", "Type", "Name", "Email"], * "rows": [ * ["uuid-1", "person", "John Doe", "john@example.com"], * ["uuid-2", "person", "Jane Doe", "jane@example.com"] * ], * "count": 2, * "hasMore": false * } * ``` * * @example * ```typescript * const sheets = new GoogleSheetsIntegration({ * basePath: '/sheets', * maxResults: 1000 * }) * await sheets.initialize() * ``` */ export class GoogleSheetsIntegration extends IntegrationBase implements HTTPIntegration { readonly name = 'sheets' port: number basePath: string private sheetsConfig: GoogleSheetsConfig & { enabled: boolean basePath: string port: number maxResults: number defaultLimit: number allowWrite: boolean allowedOrigins: string[] } private sseClients: Map void> = new Map() constructor(config?: GoogleSheetsConfig) { super(config) this.sheetsConfig = { enabled: config?.enabled ?? true, basePath: config?.basePath ?? '/sheets', port: config?.port ?? 0, maxResults: config?.maxResults ?? 1000, defaultLimit: config?.defaultLimit ?? 100, allowWrite: config?.allowWrite ?? true, allowedOrigins: config?.allowedOrigins ?? [ 'https://docs.google.com', 'https://script.google.com' ], rateLimit: config?.rateLimit, auth: config?.auth, cors: config?.cors ?? { origin: [ 'https://docs.google.com', 'https://script.google.com', '*' ], methods: ['GET', 'POST', 'OPTIONS'], credentials: true } } this.port = this.sheetsConfig.port this.basePath = this.sheetsConfig.basePath } protected async onStart(): Promise { // Subscribe to changes for real-time sync this.subscribeToChanges( { entityTypes: ['noun', 'verb'] }, (event) => { // Broadcast to all SSE clients this.broadcastSSE('change', { type: event.entityType, operation: event.operation, entityId: event.entityId, timestamp: event.timestamp }) } ) this.log('Google Sheets integration started') } protected async onStop(): Promise { // Close all SSE connections this.sseClients.clear() this.log('Google Sheets integration stopped') } /** * Handle a Sheets API request */ async handleRequest(request: SheetsRequest): Promise { this.recordRequest() try { const { method, path } = request const relativePath = path.startsWith(this.basePath) ? path.slice(this.basePath.length) : path // CORS preflight if (method === 'OPTIONS') { return this.corsResponse() } // Route the request if (method === 'GET') { if (relativePath === '/query' || relativePath === '') { return this.handleQuery(request) } if (relativePath.startsWith('/entity/')) { const id = relativePath.slice('/entity/'.length) return this.handleGetEntity(id) } if (relativePath === '/similar') { return this.handleSimilar(request) } if (relativePath === '/relations') { return this.handleRelations(request) } if (relativePath === '/schema') { return this.handleSchema() } if (relativePath === '/stream') { return this.handleStream(request) } if (relativePath === '/health') { return this.handleHealth() } } if (method === 'POST' && this.sheetsConfig.allowWrite) { if (relativePath === '/add') { return this.handleAdd(request) } if (relativePath === '/update') { return this.handleUpdate(request) } if (relativePath === '/delete') { return this.handleDelete(request) } if (relativePath === '/batch') { return this.handleBatch(request) } if (relativePath === '/relate') { return this.handleRelate(request) } } return this.errorResponse(404, 'Not Found') } catch (error: any) { this.recordError(error) return this.errorResponse(500, error.message) } } /** * Get registered routes */ getRoutes(): Array<{ method: string; path: string; description: string }> { const routes = [ { method: 'GET', path: `${this.basePath}/query`, description: 'Query entities' }, { method: 'GET', path: `${this.basePath}/entity/:id`, description: 'Get entity by ID' }, { method: 'GET', path: `${this.basePath}/similar`, description: 'Semantic search' }, { method: 'GET', path: `${this.basePath}/relations`, description: 'Get relationships' }, { method: 'GET', path: `${this.basePath}/schema`, description: 'Get schema' }, { method: 'GET', path: `${this.basePath}/stream`, description: 'SSE stream' }, { method: 'GET', path: `${this.basePath}/health`, description: 'Health check' } ] if (this.sheetsConfig.allowWrite) { routes.push( { method: 'POST', path: `${this.basePath}/add`, description: 'Add entity' }, { method: 'POST', path: `${this.basePath}/update`, description: 'Update entity' }, { method: 'POST', path: `${this.basePath}/delete`, description: 'Delete entity' }, { method: 'POST', path: `${this.basePath}/batch`, description: 'Batch operations' }, { method: 'POST', path: `${this.basePath}/relate`, description: 'Create relationship' } ) } return routes } /** * Register an SSE client */ registerSSEClient( clientId: string, sendFn: (event: string, data: any) => void ): () => void { this.sseClients.set(clientId, sendFn) return () => this.sseClients.delete(clientId) } /** * Get manifest */ getManifest(): Record { return { id: 'sheets', name: 'Google Sheets Integration', version: '1.0.0', description: 'Two-way sync between Brainy and Google Sheets', longDescription: 'Enables real-time bidirectional synchronization with Google Sheets. Use custom functions like =BRAINY_QUERY() directly in cells, or the sidebar for browsing and editing.', category: 'integration', status: 'stable', configSchema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, basePath: { type: 'string', default: '/sheets' }, maxResults: { type: 'number', default: 1000 }, defaultLimit: { type: 'number', default: 100 }, allowWrite: { type: 'boolean', default: true } } }, configDefaults: { enabled: true, basePath: '/sheets', maxResults: 1000, defaultLimit: 100, allowWrite: true }, features: [ 'Custom functions (=BRAINY_QUERY)', 'Real-time sync via SSE', 'Batch operations', 'Semantic search support', 'Type schema discovery' ], keywords: ['google-sheets', 'spreadsheet', 'sync', 'real-time'] } } // Route handlers private async handleQuery(request: SheetsRequest): Promise { const { query } = request // Build find params const findParams: FindParams = { limit: Math.min( parseInt(query.limit) || this.sheetsConfig.defaultLimit, this.sheetsConfig.maxResults ) } // Query string (semantic search) if (query.q) { findParams.query = query.q } // Type filter if (query.type) { const types = query.type.split(',') as NounType[] findParams.type = types.length === 1 ? types[0] : types } // Offset pagination if (query.offset) { findParams.offset = parseInt(query.offset) } // Sort if (query.orderBy) { findParams.orderBy = query.orderBy findParams.order = (query.order as 'asc' | 'desc') || 'desc' } // Execute query const entities = await this.queryEntities(findParams) // Convert to sheets format return this.entitiesToSheetsResponse(entities) } private async handleGetEntity(id: string): Promise { const entity = await this.getEntity(id) if (!entity) { return this.errorResponse(404, 'Entity not found') } return this.entitiesToSheetsResponse([entity]) } private async handleSimilar(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { query } = request if (!query.q && !query.to) { return this.errorResponse(400, 'Missing q or to parameter') } const limit = Math.min( parseInt(query.limit) || 10, this.sheetsConfig.maxResults ) // Semantic similarity search let results: any[] if (query.to) { // Similar to entity results = await this.context.brain.similar({ to: query.to, limit, threshold: query.threshold ? parseFloat(query.threshold) : undefined }) } else { // Similar to query text results = await this.context.brain.find({ query: query.q, limit }) } const entities = results.map((r: any) => r.entity || r) return this.entitiesToSheetsResponse(entities) } private async handleRelations( request: SheetsRequest ): Promise { const { query } = request const params: any = { limit: Math.min( parseInt(query.limit) || 100, this.sheetsConfig.maxResults ) } if (query.from) params.from = query.from if (query.to) params.to = query.to if (query.type) params.type = query.type as VerbType const relations = await this.queryRelations(params) // Convert to sheets format const headers = [ 'Id', 'FromId', 'ToId', 'Type', 'Weight', 'Confidence', 'CreatedAt' ] const rows = relations.map((r) => [ r.id, r.from, r.to, r.type, r.weight ?? 1, r.confidence ?? 1, new Date(r.createdAt).toISOString() ]) return this.jsonResponse({ headers, rows, count: rows.length, hasMore: false }) } private handleSchema(): SheetsResponse { return this.jsonResponse({ nounTypes: Object.values(NounType), verbTypes: Object.values(VerbType), entityFields: ['id', 'type', 'data', 'metadata', 'confidence', 'weight', 'service', 'createdAt', 'updatedAt'], commonMetadataFields: ['name', 'title', 'description', 'email', 'url', 'tags', 'category', 'status', 'priority'] }) } private handleStream(_request: SheetsRequest): SheetsResponse { // This returns headers for SSE - actual streaming is handled by the HTTP server return { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' }, body: 'sse' // Signal to HTTP server to handle as SSE } } private handleHealth(): SheetsResponse { return this.jsonResponse({ status: 'ok', integration: 'sheets', uptime: this.startedAt ? Date.now() - this.startedAt : 0, requests: this.requestCount }) } private async handleAdd(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { body } = request if (!body || !body.type) { return this.errorResponse(400, 'Missing required field: type') } // Honor caller-supplied `subtype` from the Sheets request; fall back to // the integration-default `'imported-from-sheets'` so enforcement // consumers don't get rejected on Sheets-driven writes (added 7.30.1). const entity = await this.context.brain.add({ type: body.type as NounType, subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets', data: body.data, metadata: body.metadata, confidence: body.confidence, weight: body.weight, service: body.service }) return this.entitiesToSheetsResponse([entity]) } private async handleUpdate(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { body } = request if (!body || !body.id) { return this.errorResponse(400, 'Missing required field: id') } await this.context.brain.update({ id: body.id, data: body.data, metadata: body.metadata, type: body.type as NounType, confidence: body.confidence, weight: body.weight, merge: body.merge ?? true }) const updated = await this.getEntity(body.id) return this.entitiesToSheetsResponse(updated ? [updated] : []) } private async handleDelete(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { body } = request if (!body || !body.id) { return this.errorResponse(400, 'Missing required field: id') } await this.context.brain.remove(body.id) return this.jsonResponse({ success: true, deleted: body.id }) } private async handleBatch(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { body } = request if (!body || !Array.isArray(body.operations)) { return this.errorResponse(400, 'Missing operations array') } const results: any[] = [] for (const op of body.operations) { try { switch (op.action) { case 'add': // Same subtype-precedence as the single-entity handler (7.30.1). const added = await this.context.brain.add({ type: op.type as NounType, subtype: (op.subtype as string | undefined) ?? 'imported-from-sheets', data: op.data, metadata: op.metadata }) results.push({ success: true, id: added.id, action: 'add' }) break case 'update': await this.context.brain.update({ id: op.id, data: op.data, metadata: op.metadata, merge: true }) results.push({ success: true, id: op.id, action: 'update' }) break case 'delete': await this.context.brain.remove(op.id) results.push({ success: true, id: op.id, action: 'delete' }) break default: results.push({ success: false, error: `Unknown action: ${op.action}` }) } } catch (error: any) { results.push({ success: false, id: op.id, action: op.action, error: error.message }) } } return this.jsonResponse({ results, total: body.operations.length, successful: results.filter((r) => r.success).length, failed: results.filter((r) => !r.success).length }) } private async handleRelate(request: SheetsRequest): Promise { if (!this.context) { return this.errorResponse(500, 'Not initialized') } const { body } = request if (!body || !body.from || !body.to || !body.type) { return this.errorResponse(400, 'Missing required fields: from, to, type') } // Same subtype precedence as entity writes: caller-supplied → default // `'imported-from-sheets'` (added 7.30.1). const relation = await this.context.brain.relate({ from: body.from, to: body.to, type: body.type as VerbType, subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets', weight: body.weight, metadata: body.metadata }) return this.jsonResponse({ success: true, relation: { id: relation.id, from: relation.from, to: relation.to, type: relation.type } }) } // Helpers private entitiesToSheetsResponse(entities: Entity[]): SheetsResponse { if (entities.length === 0) { return this.jsonResponse({ headers: ['Id', 'Type', 'CreatedAt'], rows: [], count: 0, hasMore: false }) } // Collect all unique metadata keys const metadataKeys = new Set() for (const entity of entities) { if (entity.metadata) { for (const key of Object.keys(entity.metadata)) { metadataKeys.add(key) } } } // Build headers const baseHeaders = ['Id', 'Type', 'Confidence', 'Weight', 'CreatedAt'] const metaHeaders = Array.from(metadataKeys).sort() const headers = [...baseHeaders, ...metaHeaders, 'Data'] // Build rows const rows = entities.map((entity) => { const baseValues = [ entity.id, entity.type, entity.confidence ?? '', entity.weight ?? '', new Date(entity.createdAt).toISOString() ] const metaValues = metaHeaders.map((key) => { const val = entity.metadata?.[key] if (val === undefined || val === null) return '' if (typeof val === 'object') return JSON.stringify(val) return val }) const dataValue = entity.data ? JSON.stringify(entity.data) : '' return [...baseValues, ...metaValues, dataValue] }) return this.jsonResponse({ headers, rows, count: rows.length, hasMore: false }) } private broadcastSSE(event: string, data: any): void { for (const [_, sendFn] of this.sseClients) { try { sendFn(event, data) } catch { // Client disconnected } } } private jsonResponse(data: any): SheetsResponse { return { status: 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }, body: JSON.stringify(data) } } private errorResponse(status: number, message: string): SheetsResponse { return { status, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify({ error: message }) } } private corsResponse(): SheetsResponse { return { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400' }, body: null } } } /** * Package export for @soulcraft/brainy-sheets */ export const integration = { name: 'sheets', version: '1.0.0', description: 'Google Sheets two-way sync with real-time updates', environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'], create: (brain: any, config?: GoogleSheetsConfig) => new GoogleSheetsIntegration(config), defaultConfig: { basePath: '/sheets', maxResults: 1000, defaultLimit: 100, allowWrite: true } }