feat(editor): add LocalBackendServer and BackendManager

TASK-007B: Local Backend Server (Phase 5 BYOB)

- Add LocalBackendServer with REST API for database CRUD
- Support /api/:table endpoints for query, create, update, delete
- Support /api/:table/:id for single record operations
- Support /api/_schema for schema management
- Support /api/_batch for batch operations
- Add placeholder for /functions/:name (CloudRunner integration)
- Add BackendManager for backend lifecycle (create, start, stop, delete)
- Add IPC handlers for renderer process communication
- Backends stored in ~/.noodl/backends/{id}/

Next: Integration with main process, UI for backend management
This commit is contained in:
Richard Osborne
2026-01-15 16:34:42 +01:00
parent 95bf2f363c
commit 8c0f0c6797
3 changed files with 889 additions and 0 deletions

View File

@@ -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<BackendMetadata[]>}
*/
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<BackendMetadata|null>}
*/
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<BackendMetadata>}
*/
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()
};

View File

@@ -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 };

View File

@@ -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
};