mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
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:
@@ -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()
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
33
packages/noodl-editor/src/main/src/local-backend/index.js
Normal file
33
packages/noodl-editor/src/main/src/local-backend/index.js
Normal 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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user