Finished prototype local backends and expression editor

This commit is contained in:
Richard Osborne
2026-01-16 12:00:31 +01:00
parent 94c870e5d7
commit 32a0a0885f
48 changed files with 8513 additions and 108 deletions

View File

@@ -13,17 +13,17 @@ const QueryBuilder = require('./QueryBuilder');
const SchemaManager = require('./SchemaManager');
/**
* Generate a unique object ID (similar to Parse objectId)
* Generate a UUID v4
*
* @returns {string} 10-character alphanumeric ID
* @returns {string} UUID string (e.g., "123e4567-e89b-12d3-a456-426614174000")
*/
function generateObjectId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 10; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id;
function generateUUID() {
// RFC 4122 version 4 UUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
@@ -132,17 +132,25 @@ class LocalSQLAdapter {
const self = this;
return {
ensureSchemaTable: () => {},
createTable: ({ name }) => {
createTable: ({ name, columns }) => {
if (!self._mockData[name]) {
self._mockData[name] = {};
self._mockSchema[name] = {};
self._mockSchema[name] = { name, columns: columns || [] };
}
return true;
},
addColumn: (table, col) => {
if (!self._mockSchema[table]) self._mockSchema[table] = {};
self._mockSchema[table][col.name] = col;
if (!self._mockSchema[table]) self._mockSchema[table] = { name: table, columns: [] };
if (!self._mockSchema[table].columns) self._mockSchema[table].columns = [];
// Check if column already exists
const exists = self._mockSchema[table].columns.some((c) => c.name === col.name);
if (!exists) {
self._mockSchema[table].columns.push(col);
}
},
getTableSchema: (table) => self._mockSchema[table] || {},
getTableSchema: (table) => self._mockSchema[table] || null,
listTables: () => Object.keys(self._mockData).filter((name) => !name.startsWith('_')),
exportSchemas: () => Object.values(self._mockSchema).filter((s) => s && !s.name?.startsWith('_')),
addRelation: () => {},
removeRelation: () => {}
};
@@ -153,8 +161,9 @@ class LocalSQLAdapter {
* @private
*/
_mockExec(sql, params, mode) {
// Parse simple SQL patterns for mock execution
const selectMatch = sql.match(/SELECT \* FROM "?(\w+)"?\s*(?:WHERE "?objectId"?\s*=\s*\?)?/i);
// Parse SQL patterns for mock execution
// Match SELECT with optional WHERE, ORDER BY, LIMIT, OFFSET
const selectMatch = sql.match(/SELECT\s+\*\s+FROM\s+"?(\w+)"?/i);
const insertMatch = sql.match(/INSERT INTO "?(\w+)"?/i);
const updateMatch = sql.match(/UPDATE "?(\w+)"?\s+SET/i);
const deleteMatch = sql.match(/DELETE FROM "?(\w+)"?/i);
@@ -163,48 +172,111 @@ class LocalSQLAdapter {
const table = selectMatch[1];
if (!this._mockData[table]) this._mockData[table] = {};
if (params.length > 0) {
// Single record fetch
const record = this._mockData[table][params[0]];
let records = Object.values(this._mockData[table]);
// Check for WHERE id = ? or WHERE objectId = ?
const idMatch = sql.match(/WHERE\s+"?(?:id|objectId)"?\s*=\s*\?/i);
if (idMatch && params.length > 0) {
const recordId = params[0];
const record = this._mockData[table][recordId];
return mode === 'get' ? record || null : record ? [record] : [];
}
// Return all records
const records = Object.values(this._mockData[table]);
// Handle ORDER BY
const orderMatch = sql.match(/ORDER BY\s+"?(\w+)"?\s+(ASC|DESC)?/i);
if (orderMatch) {
const orderCol = orderMatch[1];
const orderDir = (orderMatch[2] || 'ASC').toUpperCase();
records = records.sort((a, b) => {
const aVal = a[orderCol];
const bVal = b[orderCol];
if (aVal < bVal) return orderDir === 'ASC' ? -1 : 1;
if (aVal > bVal) return orderDir === 'ASC' ? 1 : -1;
return 0;
});
}
// Handle LIMIT and OFFSET
// Find position of LIMIT in params (it comes after WHERE params if any)
let paramIndex = 0;
if (sql.includes('LIMIT')) {
// Find LIMIT param position - it's after WHERE params
const limitIdx = sql.indexOf('LIMIT');
const whereClause = sql.substring(0, limitIdx);
const whereParams = (whereClause.match(/\?/g) || []).length;
paramIndex = whereParams;
const limit = params[paramIndex];
const skip = sql.includes('OFFSET') ? params[paramIndex + 1] || 0 : 0;
records = records.slice(skip, skip + limit);
}
return mode === 'get' ? records[0] || null : records;
}
if (insertMatch) {
const table = insertMatch[1];
if (!this._mockData[table]) this._mockData[table] = {};
// Find objectId in params (simple extraction)
// Parse column names from SQL: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
const columnsMatch = sql.match(/\(([^)]+)\)\s*VALUES/i);
if (columnsMatch) {
const columns = columnsMatch[1].split(',').map((c) => c.trim().replace(/"/g, ''));
const record = {};
columns.forEach((col, idx) => {
record[col] = params[idx];
});
// Ensure id exists
if (!record.id && params[0]) {
record.id = params[0];
}
const recordId = record.id;
this._mockData[table][recordId] = record;
return { changes: 1 };
}
// Fallback: simple record creation
const now = new Date().toISOString();
const record = { objectId: params[0], createdAt: now, updatedAt: now };
const record = { id: params[0], createdAt: now, updatedAt: now };
this._mockData[table][params[0]] = record;
return { changes: 1 };
}
if (updateMatch) {
const table = updateMatch[1];
// Last param is typically the objectId in WHERE clause
const objectId = params[params.length - 1];
if (this._mockData[table] && this._mockData[table][objectId]) {
this._mockData[table][objectId].updatedAt = new Date().toISOString();
// Last param is typically the id in WHERE clause
const recordId = params[params.length - 1];
if (this._mockData[table] && this._mockData[table][recordId]) {
// Parse SET clauses to update actual fields
const setMatch = sql.match(/SET\s+(.+?)\s+WHERE/i);
if (setMatch) {
const setParts = setMatch[1].split(',');
let paramIdx = 0;
setParts.forEach((part) => {
const colMatch = part.match(/"?(\w+)"?\s*=/);
if (colMatch) {
this._mockData[table][recordId][colMatch[1]] = params[paramIdx];
paramIdx++;
}
});
}
this._mockData[table][recordId].updatedAt = new Date().toISOString();
}
return { changes: 1 };
}
if (deleteMatch) {
const table = deleteMatch[1];
const objectId = params[0];
const recordId = params[0];
if (this._mockData[table]) {
delete this._mockData[table][objectId];
delete this._mockData[table][recordId];
}
return { changes: 1 };
}
// Count query
if (sql.includes('COUNT(*)')) {
const countMatch = sql.match(/FROM "?(\w+)"?/i);
const countMatch = sql.match(/FROM\s+"?(\w+)"?/i);
if (countMatch) {
const table = countMatch[1];
const count = Object.keys(this._mockData[table] || {}).length;
@@ -356,8 +428,9 @@ class LocalSQLAdapter {
try {
this._ensureTable(options.collection);
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`;
const row = this.db.prepare(sql).get(options.objectId);
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`;
const recordId = options.id || options.objectId;
const row = this.db.prepare(sql).get(recordId);
if (!row) {
options.error('Object not found');
@@ -370,7 +443,7 @@ class LocalSQLAdapter {
this.events.emit('fetch', {
type: 'fetch',
objectId: options.objectId,
id: recordId,
object: record,
collection: options.collection
});
@@ -392,22 +465,22 @@ class LocalSQLAdapter {
// Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type });
}
}
}
const objectId = generateObjectId();
const { sql, params } = QueryBuilder.buildInsert(options, objectId);
const recordId = generateUUID();
const { sql, params } = QueryBuilder.buildInsert(options, recordId);
this.db.prepare(sql).run(...params);
// Fetch the created record to get all fields
const createdRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(objectId);
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(recordId);
const record = this._rowToRecord(createdRow, options.collection);
@@ -415,7 +488,7 @@ class LocalSQLAdapter {
this.events.emit('create', {
type: 'create',
objectId,
id: recordId,
object: record,
collection: options.collection
});
@@ -437,20 +510,21 @@ class LocalSQLAdapter {
// Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type });
}
}
}
const recordId = options.id || options.objectId;
const { sql, params } = QueryBuilder.buildUpdate(options);
this.db.prepare(sql).run(...params);
// Fetch the updated record
const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(options.objectId);
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(recordId);
const record = this._rowToRecord(updatedRow, options.collection);
@@ -458,7 +532,7 @@ class LocalSQLAdapter {
this.events.emit('save', {
type: 'save',
objectId: options.objectId,
id: recordId,
object: record,
collection: options.collection
});
@@ -482,9 +556,10 @@ class LocalSQLAdapter {
options.success();
const recordId = options.id || options.objectId;
this.events.emit('delete', {
type: 'delete',
objectId: options.objectId,
id: recordId,
collection: options.collection
});
} catch (e) {
@@ -573,9 +648,10 @@ class LocalSQLAdapter {
this.db.prepare(sql).run(...params);
// Fetch the updated record
const recordId = options.id || options.objectId;
const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
.get(options.objectId);
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(recordId);
const record = this._rowToRecord(updatedRow, options.collection);
options.success(record);

View File

@@ -275,6 +275,12 @@ function translateOperator(col, op, value, params, schema) {
}
return null;
case 'contains':
case '$contains':
// Contains search - convert to LIKE with wildcards
params.push(`%${convertedValue}%`);
return `${col} LIKE ?`;
// Geo queries - not fully supported in SQLite without extensions
case '$nearSphere':
case '$within':
@@ -333,8 +339,8 @@ function buildSelect(options, schema) {
let selectClause = '*';
if (options.select) {
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
// Always include objectId
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]);
// Always include id
const fields = new Set(['id', ...selectArray.map((s) => s.trim())]);
selectClause = Array.from(fields)
.map((f) => escapeColumn(f))
.join(', ');
@@ -406,13 +412,13 @@ function buildCount(options, schema) {
* @param {string} objectId
* @returns {{ sql: string, params: Array }}
*/
function buildInsert(options, objectId) {
function buildInsert(options, id) {
const params = [];
const table = escapeTable(options.collection);
const now = new Date().toISOString();
const data = {
objectId,
id,
createdAt: now,
updatedAt: now,
...options.data
@@ -441,7 +447,7 @@ function buildInsert(options, objectId) {
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @param {string} options.id - Record ID
* @param {Object} options.data
* @returns {{ sql: string, params: Array }}
*/
@@ -455,7 +461,7 @@ function buildUpdate(options) {
data.updatedAt = new Date().toISOString();
// Remove protected fields
delete data.objectId;
delete data.id;
delete data.createdAt;
delete data._createdAt;
delete data._updatedAt;
@@ -467,9 +473,11 @@ function buildUpdate(options) {
params.push(serializeValue(value));
}
params.push(options.objectId);
// Use id or objectId for backwards compatibility
const recordId = options.id || options.objectId;
params.push(recordId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
return { sql, params };
}
@@ -479,13 +487,15 @@ function buildUpdate(options) {
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @param {string} options.id - Record ID
* @returns {{ sql: string, params: Array }}
*/
function buildDelete(options) {
const table = escapeTable(options.collection);
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`;
return { sql, params: [options.objectId] };
// Use id or objectId for backwards compatibility
const recordId = options.id || options.objectId;
const sql = `DELETE FROM ${table} WHERE "id" = ?`;
return { sql, params: [recordId] };
}
/**
@@ -493,7 +503,7 @@ function buildDelete(options) {
*
* @param {Object} options
* @param {string} options.collection
* @param {string} options.objectId
* @param {string} options.id - Record ID
* @param {Object<string, number>} options.properties
* @returns {{ sql: string, params: Array }}
*/
@@ -513,9 +523,11 @@ function buildIncrement(options) {
setClause.push('"updatedAt" = ?');
params.push(new Date().toISOString());
params.push(options.objectId);
// Use id or objectId for backwards compatibility
const recordId = options.id || options.objectId;
params.push(recordId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
return { sql, params };
}

View File

@@ -168,6 +168,89 @@ class SchemaManager {
}
}
/**
* Delete a table and all its data
*
* @param {string} tableName - Table name
* @returns {boolean} Whether table was deleted
*/
deleteTable(tableName) {
// Check if table exists
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
if (!exists) {
return false;
}
// Drop the table
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(tableName)}`);
// Remove from schema tracking
this.ensureSchemaTable();
this.db.prepare('DELETE FROM "_Schema" WHERE "name" = ?').run(tableName);
// Clear cache
this._schemaCache.delete(tableName);
// Drop any junction tables for relations
const junctionTables = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?")
.all(`_Join_%_${tableName}`);
for (const jt of junctionTables) {
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(jt.name)}`);
}
return true;
}
/**
* Rename a column in a table (SQLite 3.25.0+)
*
* @param {string} tableName - Table name
* @param {string} oldName - Current column name
* @param {string} newName - New column name
* @returns {boolean} Whether column was renamed
*/
renameColumn(tableName, oldName, newName) {
// Validate new name
if (!newName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(newName)) {
throw new Error('Invalid column name');
}
// Can't rename system columns
const systemCols = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
if (systemCols.includes(oldName)) {
throw new Error('Cannot rename system columns');
}
try {
this.db.exec(
`ALTER TABLE ${escapeTable(tableName)} RENAME COLUMN ${escapeColumn(oldName)} TO ${escapeColumn(newName)}`
);
// Update schema tracking
const schema = this.getTableSchema(tableName);
if (schema && schema.columns) {
const col = schema.columns.find((c) => c.name === oldName);
if (col) {
col.name = newName;
this.db
.prepare(`UPDATE "_Schema" SET "schema" = ?, "updatedAt" = CURRENT_TIMESTAMP WHERE "name" = ?`)
.run(JSON.stringify(schema), tableName);
this._schemaCache.set(tableName, schema);
}
}
return true;
} catch (e) {
if (e.message.includes('no such column')) {
throw new Error(`Column "${oldName}" does not exist`);
}
throw e;
}
}
/**
* Get schema for a table
*

View File

@@ -1,5 +1,10 @@
const OutputProperty = require('./outputproperty');
const { evaluateExpression } = require('./expression-evaluator');
const {
evaluateExpression,
compileExpression,
detectDependencies,
subscribeToChanges
} = require('./expression-evaluator');
const { coerceToType } = require('./expression-type-coercion');
/**
@@ -45,6 +50,9 @@ function Node(context, id) {
this._valuesFromConnections = {};
this.updateOnDirtyFlagging = true;
// Expression subscriptions: { [portName]: { unsub: unsubscribeFn, expression: string } }
this._expressionSubscriptions = {};
}
Node.prototype.getInputValue = function (name) {
@@ -101,7 +109,8 @@ Node.prototype.registerInputIfNeeded = function () {
};
/**
* Evaluate an expression parameter and return the coerced result
* Evaluate an expression parameter and return the coerced result.
* Also sets up reactive subscriptions so the node updates when dependencies change.
*
* @param {*} paramValue - The parameter value (might be an ExpressionParameter)
* @param {string} portName - The input port name
@@ -110,6 +119,14 @@ Node.prototype.registerInputIfNeeded = function () {
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
// Check if this is an expression parameter
if (!isExpressionParameter(paramValue)) {
// Clean up any existing subscription for this port since it's no longer an expression
if (this._expressionSubscriptions[portName]) {
const sub = this._expressionSubscriptions[portName];
if (sub && sub.unsub) {
sub.unsub();
}
delete this._expressionSubscriptions[portName];
}
return paramValue; // Simple value, return as-is
}
@@ -119,14 +136,63 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
}
try {
// Evaluate the expression with access to context
const result = evaluateExpression(paramValue.expression, this.context);
// Compile and evaluate the expression
// Note: We pass undefined for modelScope - evaluateExpression will use the global Model
const compiled = compileExpression(paramValue.expression);
if (!compiled) {
console.warn(`Expression compilation failed for ${this.name}.${portName}: ${paramValue.expression}`);
return paramValue.fallback;
}
const result = evaluateExpression(compiled, undefined);
// Coerce to expected type
const coercedValue = coerceToType(result, input.type, paramValue.fallback);
// Set up reactive subscription
// Track both the unsubscribe function and the expression string
// If expression changes, we need to re-subscribe with new dependencies
const currentSub = this._expressionSubscriptions[portName];
const expressionChanged = currentSub && currentSub.expression !== paramValue.expression;
// Unsubscribe if expression changed
if (expressionChanged && currentSub.unsub) {
currentSub.unsub();
delete this._expressionSubscriptions[portName];
}
// Subscribe if not subscribed or expression changed
if (!this._expressionSubscriptions[portName]) {
const dependencies = detectDependencies(paramValue.expression);
const hasDependencies =
dependencies.variables.length > 0 || dependencies.objects.length > 0 || dependencies.arrays.length > 0;
if (hasDependencies) {
// Subscribe to changes - when a dependency changes, re-queue the input
// Note: We store the expression string to detect changes later
const unsub = subscribeToChanges(
dependencies,
function () {
// Don't re-evaluate if node is deleted
if (this._deleted) return;
// Re-queue the expression parameter - it will be re-evaluated
// Use the stored input value which has the current expression
const currentValue = this._inputValues[portName];
if (isExpressionParameter(currentValue)) {
this.queueInput(portName, currentValue);
}
}.bind(this)
);
this._expressionSubscriptions[portName] = {
unsub: unsub,
expression: paramValue.expression
};
}
}
// Clear any previous expression errors
if (this.context.editorConnection) {
if (this.context && this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
@@ -140,7 +206,7 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
// Show warning in editor
if (this.context.editorConnection) {
if (this.context && this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
@@ -605,6 +671,15 @@ Node.prototype._onNodeDeleted = function () {
this._deleted = true;
// Clean up expression subscriptions
for (const portName in this._expressionSubscriptions) {
const sub = this._expressionSubscriptions[portName];
if (sub && sub.unsub) {
sub.unsub();
}
}
this._expressionSubscriptions = {};
for (const deleteListener of this._deleteListeners) {
deleteListener.call(this);
}