brainy/src/integrations/sheets/GoogleSheetsIntegration.ts

780 lines
21 KiB
TypeScript

/**
* 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<string, string>
body?: any
headers: Record<string, string>
}
/**
* Sheets API response
*/
interface SheetsResponse {
status: number
headers: Record<string, string>
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<string, (event: string, data: any) => 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<void> {
// 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<void> {
// Close all SSE connections
this.sseClients.clear()
this.log('Google Sheets integration stopped')
}
/**
* Handle a Sheets API request
*/
async handleRequest(request: SheetsRequest): Promise<SheetsResponse> {
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<string, any> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<SheetsResponse> {
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<string>()
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
}
}