diff --git a/packages/noodl-editor/src/main/src/local-backend/BackendManager.js b/packages/noodl-editor/src/main/src/local-backend/BackendManager.js new file mode 100644 index 0000000..4e73f93 --- /dev/null +++ b/packages/noodl-editor/src/main/src/local-backend/BackendManager.js @@ -0,0 +1,358 @@ +/** + * BackendManager + * + * Manages the lifecycle of local backends - creation, starting, stopping, deletion. + * Provides IPC handlers for the renderer process to interact with backends. + * + * @module local-backend/BackendManager + */ + +const { ipcMain } = require('electron'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); + +const { LocalBackendServer } = require('./LocalBackendServer'); + +/** + * Safe console.log wrapper + */ +function safeLog(...args) { + try { + console.log('[BackendManager]', ...args); + } catch (e) { + // Ignore EPIPE errors + } +} + +/** + * Generate a unique backend ID + */ +function generateBackendId() { + return 'backend_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 7); +} + +/** + * Backend metadata stored in config.json + * @typedef {Object} BackendMetadata + * @property {string} id - Unique backend ID + * @property {string} name - Display name + * @property {string} createdAt - ISO8601 timestamp + * @property {number} port - HTTP port + * @property {string[]} projectIds - Projects using this backend + */ + +/** + * BackendManager singleton class + */ +class BackendManager { + constructor() { + this.backendsPath = path.join(os.homedir(), '.noodl', 'backends'); + this.runningBackends = new Map(); // id -> LocalBackendServer + this.ipcHandlersSetup = false; + } + + /** + * Setup IPC handlers for renderer process + */ + setupIPC() { + if (this.ipcHandlersSetup) return; + + safeLog('Setting up IPC handlers'); + + // List all backends + ipcMain.handle('backend:list', async () => { + return this.listBackends(); + }); + + // Create a new backend + ipcMain.handle('backend:create', async (_, name) => { + return this.createBackend(name); + }); + + // Delete a backend + ipcMain.handle('backend:delete', async (_, id) => { + return this.deleteBackend(id); + }); + + // Start a backend + ipcMain.handle('backend:start', async (_, id) => { + return this.startBackend(id); + }); + + // Stop a backend + ipcMain.handle('backend:stop', async (_, id) => { + return this.stopBackend(id); + }); + + // Get backend status + ipcMain.handle('backend:status', async (_, id) => { + return this.getStatus(id); + }); + + // Get backend config + ipcMain.handle('backend:get', async (_, id) => { + return this.getBackend(id); + }); + + // Export schema + ipcMain.handle('backend:export-schema', async (_, id, format) => { + return this.exportSchema(id, format); + }); + + this.ipcHandlersSetup = true; + } + + /** + * Ensure backends directory exists + */ + async ensureBackendsDir() { + await fs.mkdir(this.backendsPath, { recursive: true }); + } + + /** + * List all backends + * @returns {Promise} + */ + async listBackends() { + await this.ensureBackendsDir(); + + const entries = await fs.readdir(this.backendsPath, { withFileTypes: true }); + const backends = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + try { + const configPath = path.join(this.backendsPath, entry.name, 'config.json'); + const configData = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(configData); + backends.push(config); + } catch (e) { + // Invalid backend directory, skip + safeLog(`Skipping invalid backend: ${entry.name}`, e.message); + } + } + + return backends; + } + + /** + * Get a single backend by ID + * @param {string} id + * @returns {Promise} + */ + async getBackend(id) { + const backendPath = path.join(this.backendsPath, id); + + try { + const configPath = path.join(backendPath, 'config.json'); + const configData = await fs.readFile(configPath, 'utf-8'); + return JSON.parse(configData); + } catch (e) { + return null; + } + } + + /** + * Create a new backend + * @param {string} name - Display name + * @returns {Promise} + */ + async createBackend(name) { + await this.ensureBackendsDir(); + + const id = generateBackendId(); + const backendPath = path.join(this.backendsPath, id); + + // Create directory structure + await fs.mkdir(backendPath, { recursive: true }); + await fs.mkdir(path.join(backendPath, 'data')); + await fs.mkdir(path.join(backendPath, 'workflows')); + + // Find available port + const port = await this.findAvailablePort(); + + // Create config + const config = { + id, + name, + createdAt: new Date().toISOString(), + port, + projectIds: [] + }; + + await fs.writeFile(path.join(backendPath, 'config.json'), JSON.stringify(config, null, 2)); + + // Create empty schema + await fs.writeFile(path.join(backendPath, 'schema.json'), JSON.stringify({ tables: [] }, null, 2)); + + safeLog(`Created backend: ${id} (${name}) on port ${port}`); + return config; + } + + /** + * Delete a backend + * @param {string} id + */ + async deleteBackend(id) { + // Stop if running + if (this.runningBackends.has(id)) { + await this.stopBackend(id); + } + + const backendPath = path.join(this.backendsPath, id); + + // Remove directory recursively + await fs.rm(backendPath, { recursive: true, force: true }); + + safeLog(`Deleted backend: ${id}`); + return { deleted: true, id }; + } + + /** + * Start a backend + * @param {string} id + */ + async startBackend(id) { + // Already running? + if (this.runningBackends.has(id)) { + safeLog(`Backend ${id} already running`); + return this.getStatus(id); + } + + // Load config + const config = await this.getBackend(id); + if (!config) { + throw new Error(`Backend not found: ${id}`); + } + + const backendPath = path.join(this.backendsPath, id); + + // Create and start server + const server = new LocalBackendServer({ + id: config.id, + name: config.name, + dbPath: path.join(backendPath, 'data', 'local.db'), + port: config.port + }); + + await server.start(); + this.runningBackends.set(id, server); + + safeLog(`Started backend: ${id} on port ${config.port}`); + return this.getStatus(id); + } + + /** + * Stop a backend + * @param {string} id + */ + async stopBackend(id) { + const server = this.runningBackends.get(id); + if (!server) { + safeLog(`Backend ${id} not running`); + return { running: false }; + } + + await server.stop(); + this.runningBackends.delete(id); + + safeLog(`Stopped backend: ${id}`); + return { running: false }; + } + + /** + * Get backend status + * @param {string} id + * @returns {{ running: boolean, port?: number, endpoint?: string }} + */ + getStatus(id) { + const server = this.runningBackends.get(id); + if (!server) { + return { running: false }; + } + + return { + running: true, + port: server.config.port, + endpoint: `http://localhost:${server.config.port}` + }; + } + + /** + * Export backend schema + * @param {string} id + * @param {'postgres'|'supabase'|'json'} format + */ + async exportSchema(id, format = 'json') { + const server = this.runningBackends.get(id); + if (!server) { + throw new Error('Backend must be running to export schema'); + } + + const adapter = server.getAdapter(); + if (!adapter || !adapter.schemaManager) { + throw new Error('Adapter or schema manager not available'); + } + + switch (format) { + case 'postgres': + return adapter.schemaManager.generatePostgresSQL(); + case 'supabase': + return adapter.schemaManager.generateSupabaseSQL(); + case 'json': + default: + const schema = await adapter.schemaManager.exportSchema(); + return JSON.stringify(schema, null, 2); + } + } + + /** + * Find an available port starting from 8578 + * (8577 is used by cloud-function-server) + */ + async findAvailablePort() { + const backends = await this.listBackends(); + const usedPorts = new Set(backends.map((b) => b.port)); + + // Also check running backends in case config ports changed + for (const server of this.runningBackends.values()) { + usedPorts.add(server.config.port); + } + + let port = 8578; + while (usedPorts.has(port)) { + port++; + } + + return port; + } + + /** + * Stop all running backends (for cleanup on app exit) + */ + async stopAll() { + safeLog(`Stopping ${this.runningBackends.size} backends`); + + for (const [id, server] of this.runningBackends) { + try { + await server.stop(); + safeLog(`Stopped backend: ${id}`); + } catch (e) { + safeLog(`Error stopping backend ${id}:`, e); + } + } + + this.runningBackends.clear(); + } +} + +// Singleton instance +const backendManager = new BackendManager(); + +module.exports = { + BackendManager, + backendManager, + setupBackendIPC: () => backendManager.setupIPC() +}; diff --git a/packages/noodl-editor/src/main/src/local-backend/LocalBackendServer.js b/packages/noodl-editor/src/main/src/local-backend/LocalBackendServer.js new file mode 100644 index 0000000..919ba46 --- /dev/null +++ b/packages/noodl-editor/src/main/src/local-backend/LocalBackendServer.js @@ -0,0 +1,498 @@ +/** + * LocalBackendServer + * + * Express server providing REST API for local SQLite database operations. + * This server runs alongside the editor and provides: + * - Auto-REST endpoints for database tables (/api/:table) + * - Schema management endpoints + * - Health check + * - WebSocket for realtime updates + * + * @module local-backend/LocalBackendServer + */ + +const http = require('http'); +const EventEmitter = require('events'); + +// Using native http.IncomingMessage handling instead of Express for lighter weight +// This keeps the main process simple and avoids additional dependencies + +/** + * Generate a unique ID for database records + */ +function generateObjectId() { + return 'obj_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + +/** + * Safe console.log wrapper to prevent EPIPE errors + */ +function safeLog(...args) { + try { + console.log('[LocalBackend]', ...args); + } catch (e) { + // Ignore EPIPE errors + } +} + +/** + * LocalBackendServer class + */ +class LocalBackendServer { + /** + * @param {Object} config + * @param {string} config.id - Backend ID + * @param {string} config.name - Backend display name + * @param {string} config.dbPath - Path to SQLite database + * @param {number} config.port - Port to listen on + */ + constructor(config) { + this.config = config; + this.server = null; + this.adapter = null; + this.events = new EventEmitter(); + this.wsClients = new Set(); + } + + /** + * Initialize the database adapter + */ + async initAdapter() { + // Import adapter dynamically to avoid circular deps + const { LocalSQLAdapter } = require('../../../../noodl-runtime/src/api/adapters/local-sql'); + this.adapter = new LocalSQLAdapter(this.config.dbPath); + await this.adapter.connect(); + + // Forward adapter events to WebSocket clients + this.adapter.on('create', (data) => this.broadcast('create', data)); + this.adapter.on('save', (data) => this.broadcast('save', data)); + this.adapter.on('delete', (data) => this.broadcast('delete', data)); + } + + /** + * Parse JSON body from request + */ + parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + // Limit body size to 10MB + if (body.length > 10 * 1024 * 1024) { + reject(new Error('Request body too large')); + } + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (e) { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); + } + + /** + * Send JSON response + */ + sendJSON(res, status, data, headers = {}) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Content-Length': Buffer.byteLength(body), + ...headers + }); + res.end(body); + } + + /** + * Send error response + */ + sendError(res, status, message) { + this.sendJSON(res, status, { error: message }); + } + + /** + * Parse URL and query parameters + */ + parseURL(url) { + const [pathname, queryString] = url.split('?'); + const query = {}; + if (queryString) { + queryString.split('&').forEach((pair) => { + const [key, value] = pair.split('='); + query[decodeURIComponent(key)] = decodeURIComponent(value || ''); + }); + } + return { pathname, query }; + } + + /** + * Handle incoming HTTP requests + */ + async handleRequest(req, res) { + const { pathname, query } = this.parseURL(req.url); + + // CORS preflight + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Max-Age': '86400' + }); + res.end(); + return; + } + + try { + // Route: Health check + if (pathname === '/health' && req.method === 'GET') { + return this.sendJSON(res, 200, { + status: 'ok', + backend: this.config.name, + id: this.config.id, + port: this.config.port + }); + } + + // Route: Schema + if (pathname === '/api/_schema') { + if (req.method === 'GET') { + return await this.handleGetSchema(res); + } + if (req.method === 'POST') { + const body = await this.parseBody(req); + return await this.handleUpdateSchema(res, body); + } + } + + // Route: Batch operations + if (pathname === '/api/_batch' && req.method === 'POST') { + const body = await this.parseBody(req); + return await this.handleBatch(res, body); + } + + // Route: Table operations (/api/:table) + const tableMatch = pathname.match(/^\/api\/([^/]+)$/); + if (tableMatch) { + const table = decodeURIComponent(tableMatch[1]); + + if (req.method === 'GET') { + return await this.handleQuery(res, table, query); + } + if (req.method === 'POST') { + const body = await this.parseBody(req); + return await this.handleCreate(res, table, body); + } + } + + // Route: Record operations (/api/:table/:id) + const recordMatch = pathname.match(/^\/api\/([^/]+)\/([^/]+)$/); + if (recordMatch) { + const table = decodeURIComponent(recordMatch[1]); + const id = decodeURIComponent(recordMatch[2]); + + if (req.method === 'GET') { + return await this.handleFetch(res, table, id); + } + if (req.method === 'PUT') { + const body = await this.parseBody(req); + return await this.handleSave(res, table, id, body); + } + if (req.method === 'DELETE') { + return await this.handleDelete(res, table, id); + } + } + + // Route: Cloud Functions (/functions/:name) - placeholder for CloudRunner + const functionMatch = pathname.match(/^\/functions\/([^/]+)$/); + if (functionMatch && req.method === 'POST') { + const functionName = decodeURIComponent(functionMatch[1]); + const body = await this.parseBody(req); + return await this.handleFunction(res, functionName, body, req.headers); + } + + // 404 for unmatched routes + this.sendError(res, 404, 'Not found'); + } catch (error) { + safeLog('Request error:', error); + this.sendError(res, 500, error.message); + } + } + + // ========================================================================== + // ROUTE HANDLERS + // ========================================================================== + + /** + * GET /api/:table - Query records + */ + async handleQuery(res, table, query) { + const options = { + collection: table, + limit: query.limit ? parseInt(query.limit) : 100, + skip: query.skip ? parseInt(query.skip) : 0 + }; + + if (query.where) { + try { + options.where = JSON.parse(query.where); + } catch (e) { + return this.sendError(res, 400, 'Invalid where parameter'); + } + } + + if (query.sort) { + try { + options.sort = JSON.parse(query.sort); + } catch (e) { + return this.sendError(res, 400, 'Invalid sort parameter'); + } + } + + if (query.include) { + options.include = query.include.split(','); + } + + const results = await this.adapter.query(options); + this.sendJSON(res, 200, { results, count: results.length }); + } + + /** + * GET /api/:table/:id - Fetch single record + */ + async handleFetch(res, table, id) { + const record = await this.adapter.fetch({ + collection: table, + objectId: id + }); + + if (!record) { + return this.sendError(res, 404, 'Record not found'); + } + + this.sendJSON(res, 200, record); + } + + /** + * POST /api/:table - Create record + */ + async handleCreate(res, table, data) { + const record = await this.adapter.create({ + collection: table, + data + }); + + this.sendJSON(res, 201, record); + } + + /** + * PUT /api/:table/:id - Update record + */ + async handleSave(res, table, id, data) { + await this.adapter.save({ + collection: table, + objectId: id, + data + }); + + // Fetch updated record + const record = await this.adapter.fetch({ + collection: table, + objectId: id + }); + + this.sendJSON(res, 200, record); + } + + /** + * DELETE /api/:table/:id - Delete record + */ + async handleDelete(res, table, id) { + await this.adapter.delete({ + collection: table, + objectId: id + }); + + this.sendJSON(res, 200, { deleted: true, objectId: id }); + } + + /** + * GET /api/_schema - Get schema + */ + async handleGetSchema(res) { + const schema = await this.adapter.getSchema(); + this.sendJSON(res, 200, schema); + } + + /** + * POST /api/_schema - Update schema + */ + async handleUpdateSchema(res, body) { + const { table, columns, action } = body; + + if (action === 'createTable') { + await this.adapter.schemaManager.createTable({ name: table, columns }); + return this.sendJSON(res, 200, { success: true, action: 'createTable', table }); + } + + if (action === 'addColumn') { + await this.adapter.schemaManager.addColumn(table, body.column); + return this.sendJSON(res, 200, { success: true, action: 'addColumn', table }); + } + + this.sendError(res, 400, 'Unknown schema action'); + } + + /** + * POST /api/_batch - Batch operations + */ + async handleBatch(res, body) { + const { operations } = body; + if (!Array.isArray(operations)) { + return this.sendError(res, 400, 'operations must be an array'); + } + + const results = []; + for (const op of operations) { + try { + let result; + switch (op.method) { + case 'create': + result = await this.adapter.create({ + collection: op.collection, + data: op.data + }); + break; + case 'save': + await this.adapter.save({ + collection: op.collection, + objectId: op.objectId, + data: op.data + }); + result = { success: true }; + break; + case 'delete': + await this.adapter.delete({ + collection: op.collection, + objectId: op.objectId + }); + result = { deleted: true }; + break; + default: + result = { error: `Unknown method: ${op.method}` }; + } + results.push(result); + } catch (e) { + results.push({ error: e.message }); + } + } + + this.sendJSON(res, 200, { results }); + } + + /** + * POST /functions/:name - Execute cloud function + * Placeholder - will be implemented with CloudRunner + */ + async handleFunction(res, functionName, body, headers) { + // TODO: Integrate with CloudRunner when TASK-007C is complete + safeLog(`Cloud function called: ${functionName}`); + this.sendError(res, 501, 'Cloud functions not yet implemented'); + } + + // ========================================================================== + // WEBSOCKET (TODO - Basic implementation) + // ========================================================================== + + /** + * Broadcast event to all WebSocket clients + */ + broadcast(event, data) { + const message = JSON.stringify({ + event, + data, + timestamp: Date.now() + }); + + for (const client of this.wsClients) { + try { + client.send(message); + } catch (e) { + // Client disconnected + this.wsClients.delete(client); + } + } + } + + // ========================================================================== + // LIFECYCLE + // ========================================================================== + + /** + * Start the server + */ + async start() { + // Initialize adapter + await this.initAdapter(); + + // Create HTTP server + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + // Start listening + return new Promise((resolve, reject) => { + this.server.on('error', (e) => { + safeLog('Server error:', e); + reject(e); + }); + + this.server.listen(this.config.port, () => { + safeLog(`Server running on port ${this.config.port}`); + resolve(); + }); + }); + } + + /** + * Stop the server + */ + async stop() { + // Close WebSocket clients + for (const client of this.wsClients) { + try { + client.close(); + } catch (e) { + // Ignore + } + } + this.wsClients.clear(); + + // Close HTTP server + if (this.server) { + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + + // Disconnect adapter + if (this.adapter) { + await this.adapter.disconnect(); + } + } + + /** + * Get adapter for direct access + */ + getAdapter() { + return this.adapter; + } +} + +module.exports = { LocalBackendServer, generateObjectId }; diff --git a/packages/noodl-editor/src/main/src/local-backend/index.js b/packages/noodl-editor/src/main/src/local-backend/index.js new file mode 100644 index 0000000..f0e6600 --- /dev/null +++ b/packages/noodl-editor/src/main/src/local-backend/index.js @@ -0,0 +1,33 @@ +/** + * Local Backend Module + * + * Provides a zero-configuration local backend for database operations. + * Entry point for Phase 5 TASK-007B: Local Backend Server. + * + * Usage in main process: + * const { setupBackendIPC } = require('./local-backend'); + * setupBackendIPC(); // Sets up IPC handlers + * + * Usage from renderer: + * await window.electronAPI.invoke('backend:list'); + * await window.electronAPI.invoke('backend:create', 'My Backend'); + * await window.electronAPI.invoke('backend:start', backendId); + * + * @module local-backend + */ + +const { LocalBackendServer, generateObjectId } = require('./LocalBackendServer'); +const { BackendManager, backendManager, setupBackendIPC } = require('./BackendManager'); + +module.exports = { + // Classes + LocalBackendServer, + BackendManager, + + // Singleton instance + backendManager, + + // Convenience functions + setupBackendIPC, + generateObjectId +};