Skip to content

Auto-Register Plugins for Custom Entity Schemas - Enable Hot-Reload Slash Commands #447

Description

@malibio

Parent Issue

Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation

Problem

When a user creates a custom entity schema (e.g., "invoice"), the schema is stored in the database but does not automatically become available as a slash command. Users must manually register a plugin, which defeats the purpose of dynamic entity creation.

Current workflow (manual):

// 1. User creates schema via AI
await schemaService.createSchema({ id: 'invoice', fields: [...] });

// 2. Developer must manually register plugin (users can't do this!)
pluginRegistry.register({
  id: 'invoice',
  name: 'Invoice',
  config: { slashCommands: [...] }
});

// 3. Only then does "/invoice" appear in slash menu

Desired workflow (automatic):

// 1. User creates schema via AI
await schemaService.createSchema({ id: 'invoice', fields: [...] });

// 2. Plugin auto-registered immediately ✨
// 3. "/invoice" appears in slash menu (no restart needed)

Solution

Implement immediate plugin registration when schemas are created. This leverages the existing hot-reloading capabilities discovered in the plugin registry.

Architecture

Key Discovery: The plugin registry already supports runtime registration without restart!

  • pluginRegistry.register() can be called anytime
  • SlashCommandService.getCommands() queries registry fresh on every / trigger
  • No caching layer blocks dynamic updates

Implementation Approach: Schema-to-Plugin Converter

Create a utility that automatically registers plugins when schemas are created:

// packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts

export interface SchemaPluginConfig {
  id: string;
  name: string;
  description?: string;
  icon?: string;
}

/**
 * Convert a schema definition into a plugin definition
 */
export function createPluginFromSchema(
  schema: SchemaDefinition,
  schemaId: string
): PluginDefinition {
  return {
    id: schemaId,
    name: schema.content || schemaId,
    config: {
      slashCommands: [
        {
          id: schemaId,
          name: schema.content || schemaId,
          description: schema.properties?.description || `Create ${schema.content || schemaId}`,
          contentTemplate: '',
          nodeType: schemaId,
          priority: 50  // Lower than core types (100), higher than user commands (0)
        }
      ],
      canHaveChildren: true,
      canBeChild: true
    },
    node: {
      // Use generic custom entity component
      lazyLoad: () => import('../design/components/custom-entity-node.svelte')
    }
    // No custom viewer - falls back to BaseNodeViewer
  };
}

/**
 * Register a schema as a plugin immediately
 */
export async function registerSchemaPlugin(schemaId: string): Promise<void> {
  const schema = await schemaService.getSchema(schemaId);
  
  // Don't register core types (already registered in core-plugins.ts)
  if (schema.properties?.is_core === true) {
    return;
  }
  
  const plugin = createPluginFromSchema(schema, schemaId);
  pluginRegistry.register(plugin);
  
  console.log(`[SchemaPluginLoader] Registered plugin for custom entity: ${schemaId}`);
}

/**
 * Unregister a schema plugin
 */
export function unregisterSchemaPlugin(schemaId: string): void {
  pluginRegistry.unregister(schemaId);
  console.log(`[SchemaPluginLoader] Unregistered plugin: ${schemaId}`);
}

Integration Points

1. Schema Creation Flow (packages/desktop-app/src-tauri/src/commands/schemas.rs):

Add Tauri event emission when schemas are created:

#[tauri::command]
pub async fn create_schema(
    state: State<'_, AppState>,
    app_handle: tauri::AppHandle,
    schema_id: String,
    fields: Vec<SchemaField>,
) -> Result<SchemaDefinition, String> {
    let schema = state.schema_service.create_schema(&schema_id, fields).await
        .map_err(|e| e.to_string())?;
    
    // Emit event for frontend to register plugin
    app_handle.emit_all("schema:created", SchemaCreatedPayload {
        schema_id: schema_id.clone(),
        is_core: schema.properties.is_core
    }).map_err(|e| e.to_string())?;
    
    Ok(schema)
}

2. Frontend Event Listener (App initialization or root layout):

// packages/desktop-app/src/lib/plugins/schema-plugin-loader.ts (continued)

/**
 * Initialize schema plugin auto-registration
 * Call this once during app startup
 */
export async function initializeSchemaPluginSystem(): Promise<void> {
  // Register existing custom entity schemas on startup
  await registerExistingSchemas();
  
  // Listen for new schema creation events
  await listen<SchemaCreatedEvent>('schema:created', async (event) => {
    const { schema_id, is_core } = event.payload;
    
    if (!is_core) {
      await registerSchemaPlugin(schema_id);
    }
  });
  
  // Listen for schema deletion events
  await listen<SchemaDeletedEvent>('schema:deleted', (event) => {
    unregisterSchemaPlugin(event.payload.schema_id);
  });
}

/**
 * Register all existing custom entity schemas on app startup
 */
async function registerExistingSchemas(): Promise<void> {
  const schemas = await schemaService.getAllSchemas();
  
  for (const schema of schemas) {
    if (schema.properties?.is_core === false) {
      await registerSchemaPlugin(schema.id);
    }
  }
  
  console.log(`[SchemaPluginLoader] Registered ${schemas.length} custom entity schemas`);
}

3. App Initialization (packages/desktop-app/src/routes/+layout.svelte):

<script lang="ts">
  import { onMount } from 'svelte';
  import { initializeSchemaPluginSystem } from '$lib/plugins/schema-plugin-loader';
  
  onMount(async () => {
    await initializeSchemaPluginSystem();
  });
</script>

Acceptance Criteria

Functional Requirements

  • Creating a schema immediately registers a plugin (no manual step)
  • Slash command appears in dropdown without restart
  • Custom entity types work with /type-name syntax
  • Deleting a schema unregisters the plugin
  • Core types are not re-registered (skip if is_core: true)
  • Hot-reloading works (no restart required)

Technical Requirements

  • schema-plugin-loader.ts utility created
  • createPluginFromSchema() converts schemas to plugins
  • registerSchemaPlugin() handles registration
  • initializeSchemaPluginSystem() sets up event listeners
  • Tauri events emitted on schema creation/deletion
  • Existing schemas registered on app startup
  • Generic CustomEntityNode component used for all custom types

Testing Requirements

  • Unit tests: createPluginFromSchema() conversion logic
  • Unit tests: Plugin registration/unregistration
  • Integration tests: Create schema → plugin appears
  • Integration tests: Delete schema → plugin removed
  • Integration tests: Startup registration of existing schemas
  • E2E tests: Create schema → type /invoice → node created

Implementation Details

Custom Entity Node Component

Create a generic component for all custom entity types:

<!-- packages/desktop-app/src/lib/design/components/custom-entity-node.svelte -->
<script lang="ts">
  import BaseNode from './base-node.svelte';
  import SchemaPropertyForm from '$lib/components/property-forms/schema-property-form.svelte';
  
  let { 
    nodeId,
    nodeType,
    content,
    properties 
  } = $props();
  
  // Load schema for this entity type
  let schema = $state(null);
  
  $effect(async () => {
    try {
      schema = await schemaService.getSchema(nodeType);
    } catch (error) {
      console.error(`[CustomEntityNode] Failed to load schema for ${nodeType}:`, error);
    }
  });
</script>

<div class="custom-entity-node">
  <BaseNode {nodeId} {nodeType} {content} {properties} />
  
  {#if schema}
    <SchemaPropertyForm {nodeId} {nodeType} />
  {/if}
</div>

<style>
  .custom-entity-node {
    /* Custom entity styling */
  }
</style>

Icon Handling

For MVP, use generic icon (text-circle fallback). Future enhancement can add custom icons via schema properties.

Priority Sorting

Custom entity slash commands are assigned priority 50:

  • Core types (text, task, etc.): priority 100 (appear first)
  • Custom entities: priority 50 (appear in middle)
  • User-defined commands: priority 0 (appear last)

Testing Plan

Unit Tests

// schema-plugin-loader.test.ts

describe('createPluginFromSchema', () => {
  it('converts schema to plugin definition', () => {
    const schema = {
      id: 'invoice',
      content: 'Invoice',
      properties: {
        is_core: false,
        description: 'Track invoices with amount and vendor'
      }
    };
    
    const plugin = createPluginFromSchema(schema, 'invoice');
    
    expect(plugin.id).toBe('invoice');
    expect(plugin.name).toBe('Invoice');
    expect(plugin.config.slashCommands).toHaveLength(1);
    expect(plugin.config.slashCommands[0].nodeType).toBe('invoice');
  });
  
  it('skips core types', async () => {
    const coreSchema = {
      properties: { is_core: true }
    };
    
    await registerSchemaPlugin('task');
    // Should not register (core type)
    expect(pluginRegistry.hasPlugin('task')).toBe(true); // Already registered by core
  });
});

Integration Tests

describe('Schema plugin auto-registration', () => {
  it('registers plugin when schema created', async () => {
    // Create schema
    await schemaService.createSchema('invoice', fields);
    
    // Wait for event processing
    await waitFor(() => pluginRegistry.hasPlugin('invoice'));
    
    // Verify slash command
    const commands = slashCommandService.getCommands();
    expect(commands.find(c => c.id === 'invoice')).toBeDefined();
  });
  
  it('unregisters plugin when schema deleted', async () => {
    await schemaService.createSchema('invoice', fields);
    await waitFor(() => pluginRegistry.hasPlugin('invoice'));
    
    await schemaService.deleteSchema('invoice');
    await waitFor(() => !pluginRegistry.hasPlugin('invoice'));
    
    const commands = slashCommandService.getCommands();
    expect(commands.find(c => c.id === 'invoice')).toBeUndefined();
  });
});

Dependencies

Performance Considerations

  • Startup overhead: ~10-50ms to register existing schemas (acceptable)
  • Runtime registration: <5ms per schema (negligible)
  • Memory usage: ~1KB per custom entity plugin (negligible)

Optimization opportunity (future):

  • Cache schema-to-plugin conversions
  • Lazy load CustomEntityNode component only when first used

Migration Notes

No migration required - This is additive functionality. Existing core plugins continue to work as-is.

Deployment notes:

Documentation Updates

  • Add schema plugin auto-registration section to plugin documentation
  • Document custom entity creation workflow
  • Add examples of custom entity types
  • Update architecture diagrams with auto-registration flow

Success Metrics

  • Custom entity slash commands appear <100ms after schema creation
  • Zero manual plugin registration required
  • 100% hot-reload success rate (no restarts needed)
  • No performance degradation in slash command dropdown

Labels: foundation, frontend, typescript, plugin-system
Priority: High (critical for custom entity feature)
Estimated effort: 6-8 hours

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions