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

Activity

  1. malibio commented on Nov 17, 2025

    @malibio
    CollaboratorAuthor

    Test Baseline

    Recorded before starting work on issue #447:

    Test Results:

    • Unit Tests: 1554 passed, 39 failed, 62 skipped (1655 total)
    • Test Files: 87 passed, 1 failed, 7 skipped (95 total)

    Known Failures:

    • 39 test failures in http-adapter-surrealdb.test.ts (pre-existing, related to dev proxy connection issues)

    Goal: Ensure no NEW test failures are introduced during this implementation.

  2. self-assigned this
    on Nov 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions