mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +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