diff --git a/content/docs/guides/metadata-extensions.mdx b/content/docs/guides/metadata-extensions.mdx new file mode 100644 index 0000000000..13bff607f1 --- /dev/null +++ b/content/docs/guides/metadata-extensions.mdx @@ -0,0 +1,426 @@ +--- +title: Metadata Extension Protocol +description: How to extend ObjectStack metadata definitions with custom properties using the Extension Protocol +--- + +# Metadata Extension Protocol + +The **Metadata Extension Protocol** enables plugins and modules to add custom properties to ObjectStack's core metadata definitions (Objects, Fields, Views, etc.) without modifying the core schemas. + +## Overview + +ObjectStack follows a strict **"Core + Extensions"** architecture. The core protocol defines standard metadata properties, while plugins can extend these definitions with their own custom properties using a **namespaced extension system**. + +### Key Concepts + +1. **Namespaced Keys**: All extensions use dot notation: `plugin_id.property_name` +2. **Type-Safe**: Extensions are validated at runtime using Zod schemas +3. **Non-Breaking**: Extensions are optional and backward-compatible +4. **Plugin-Scoped**: Each plugin owns its extension namespace + +## Extension Schema + +### ExtensionsMap + +The `ExtensionsMap` is a record of extension properties keyed by namespaced identifiers. + +```typescript +import { ExtensionsMap } from '@objectstack/spec'; + +const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'crm_sync.salesforceId': 'Contact.Email__c', + 'workflow_engine.triggers': ['onCreate', 'onUpdate'] +}; +``` + +### Naming Convention + +- **Namespace**: Plugin ID in `snake_case` (e.g., `ai_assistant`, `workflow_engine`) +- **Property**: Property name in `camelCase` (e.g., `vectorIndexed`, `embeddingModel`) +- **Full Key**: `namespace.property` (e.g., `ai_assistant.vectorIndexed`) + +## Using Extensions + +### Adding Extensions to Fields + +```typescript +import { FieldSchema, Field } from '@objectstack/spec'; + +// Define a field with AI extensions +const field = { + name: 'description', + label: 'Description', + type: 'textarea', + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + 'ai_assistant.chunkOverlap': 50 + } +}; + +// Validate with Zod +FieldSchema.parse(field); // ✓ Valid +``` + +### Adding Extensions to Objects + +```typescript +import { ObjectSchema } from '@objectstack/spec'; + +const object = { + name: 'customer', + label: 'Customer', + fields: { + name: { type: 'text', label: 'Name' }, + description: { type: 'textarea', label: 'Description' } + }, + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['name', 'description'], + 'ai_assistant.vectorIndex': 'customers_v1', + 'workflow_engine.autoApprovalRules': [ + { field: 'amount', operator: '<', value: 1000 } + ] + } +}; + +ObjectSchema.parse(object); // ✓ Valid +``` + +## Extension Helper Functions + +The `Extension` utility provides type-safe helper functions for working with extensions. + +### Extension.get() + +Get an extension value with optional default. + +```typescript +import { Extension } from '@objectstack/spec'; + +const vectorIndexed = Extension.get( + field.extensions, + 'ai_assistant.vectorIndexed', + false // default value +); +// Returns: true | false +``` + +### Extension.set() + +Set an extension value (returns new object, doesn't mutate). + +```typescript +let extensions = Extension.set( + undefined, + 'ai_assistant.vectorIndexed', + true +); + +extensions = Extension.set( + extensions, + 'ai_assistant.embeddingModel', + 'text-embedding-3-small' +); +// Returns: { 'ai_assistant.vectorIndexed': true, ... } +``` + +### Extension.has() + +Check if an extension key exists. + +```typescript +const hasVectorIndex = Extension.has( + field.extensions, + 'ai_assistant.vectorIndexed' +); +// Returns: boolean +``` + +### Extension.remove() + +Remove an extension (returns new object). + +```typescript +const updated = Extension.remove( + field.extensions, + 'ai_assistant.vectorIndexed' +); +// Returns: ExtensionsMap without the removed key +``` + +## Defining Extension Schemas + +Plugins should define their extension schemas using `ExtensionDefinition` for documentation and validation. + +### Extension Definition + +```typescript +import { ExtensionDefinition } from '@objectstack/spec'; + +export const VectorIndexedExtension: ExtensionDefinition = { + key: 'ai_assistant.vectorIndexed', + pluginId: 'ai_assistant', + label: 'Vector Indexed', + description: 'Enable vector indexing for semantic search', + type: 'boolean', + default: false, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown'], + required: false +}; +``` + +### Extension Registry + +Plugins can register their extensions for runtime introspection. + +```typescript +import { ExtensionRegistry } from '@objectstack/spec'; + +const registry: ExtensionRegistry = { + extensions: { + 'ai_assistant.vectorIndexed': VectorIndexedExtension, + 'ai_assistant.embeddingModel': EmbeddingModelExtension, + // ... more extensions + } +}; +``` + +## AI Extension Examples + +ObjectStack includes reference AI extensions for common use cases. + +### Field Extensions + +```typescript +import { AIFieldExtensions } from '@objectstack/spec'; + +// Vector indexing for semantic search +{ + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + 'ai_assistant.chunkOverlap': 50 +} + +// Auto-summarization +{ + 'ai_assistant.autoSummarize': true, + 'ai_assistant.summaryModel': 'gpt-4o-mini', + 'ai_assistant.summaryMaxLength': 200 +} + +// Sentiment analysis +{ + 'ai_assistant.sentimentAnalysis': true, + 'ai_assistant.sentimentField': 'sentiment_score' +} +``` + +### Object Extensions + +```typescript +import { AIObjectExtensions } from '@objectstack/spec'; + +// RAG (Retrieval-Augmented Generation) +{ + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'description'], + 'ai_assistant.vectorIndex': 'knowledge_base_v1' +} + +// AI Agent integration +{ + 'ai_assistant.agentEnabled': true, + 'ai_assistant.agentName': 'support_assistant', + 'ai_assistant.agentTriggers': ['onCreate', 'onUpdate'] +} + +// Predictive analytics +{ + 'ai_assistant.predictiveEnabled': true, + 'ai_assistant.predictiveModels': [ + { + name: 'win_probability', + type: 'classification', + targetField: 'stage' + } + ] +} + +// Auto-classification +{ + 'ai_assistant.autoClassification': true, + 'ai_assistant.classificationField': 'category', + 'ai_assistant.classificationModel': 'gpt-4o-mini' +} +``` + +## Complete Example + +Here's a complete example combining object and field extensions: + +```typescript +import { ObjectSchema, Field } from '@objectstack/spec'; + +const customerInquiry = { + name: 'customer_inquiry', + label: 'Customer Inquiry', + pluralLabel: 'Customer Inquiries', + description: 'Customer support inquiries with AI assistance', + icon: 'message-square', + + fields: { + title: { + name: 'title', + label: 'Title', + type: 'text', + required: true, + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small' + } + }, + + description: { + name: 'description', + label: 'Description', + type: 'textarea', + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.chunkSize': 512, + 'ai_assistant.autoSummarize': true, + 'ai_assistant.summaryModel': 'gpt-4o-mini' + } + }, + + customer_feedback: { + name: 'customer_feedback', + label: 'Customer Feedback', + type: 'textarea', + extensions: { + 'ai_assistant.sentimentAnalysis': true, + 'ai_assistant.sentimentField': 'sentiment_score' + } + } + }, + + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'description'], + 'ai_assistant.vectorIndex': 'customer_inquiries_v1', + 'ai_assistant.agentEnabled': true, + 'ai_assistant.agentName': 'support_assistant', + 'ai_assistant.agentTriggers': ['onCreate', 'onUpdate'], + 'ai_assistant.autoClassification': true, + 'ai_assistant.classificationField': 'category' + } +}; + +// Validate the schema +ObjectSchema.parse(customerInquiry); // ✓ Valid +``` + +## Best Practices + +### 1. Use Namespacing + +Always namespace your extensions to avoid conflicts: + +```typescript +// ✓ Good +'my_plugin.myProperty': value + +// ✗ Bad (no namespace) +'myProperty': value +``` + +### 2. Document Your Extensions + +Create `ExtensionDefinition` entries for all your extensions: + +```typescript +export const MyExtension: ExtensionDefinition = { + key: 'my_plugin.myProperty', + pluginId: 'my_plugin', + label: 'My Property', + description: 'What this extension does', + type: 'boolean', + appliesTo: ['field', 'object'], + required: false +}; +``` + +### 3. Validate Extension Values + +Use Zod schemas to validate your extension values: + +```typescript +import { z } from 'zod'; + +const MyExtensionSchema = z.object({ + 'my_plugin.myProperty': z.boolean(), + 'my_plugin.myConfig': z.object({ + enabled: z.boolean(), + threshold: z.number() + }) +}).partial(); +``` + +### 4. Use Helper Functions + +Prefer the `Extension` helpers over direct object manipulation: + +```typescript +// ✓ Good +const value = Extension.get(extensions, 'my_plugin.myProperty', false); + +// ✗ Less ideal +const value = extensions?.['my_plugin.myProperty'] ?? false; +``` + +### 5. Consider Backward Compatibility + +Extensions should be optional and have sensible defaults: + +```typescript +const enabled = Extension.get( + object.extensions, + 'my_plugin.enabled', + false // Safe default if extension not present +); +``` + +## Migration Guide + +### From Custom Properties to Extensions + +If you've been adding custom properties directly to objects/fields, migrate to extensions: + +```typescript +// Before (not recommended) +const field = { + type: 'text', + myCustomProperty: 'value' // ✗ Not in schema +}; + +// After (using extensions) +const field = { + type: 'text', + extensions: { + 'my_plugin.myCustomProperty': 'value' // ✓ Valid extension + } +}; +``` + +## See Also + +- [AI Field Extensions](/docs/references/ai/field-extensions/AIFieldExtension) +- [AI Object Extensions](/docs/references/ai/object-extensions/AIObjectExtension) +- [Plugin Architecture](/docs/concepts/plugin-architecture) +- [Field Schema](/docs/references/data/field/Field) +- [Object Schema](/docs/references/data/object/Object) diff --git a/content/docs/references/ai/field-extensions/AIFieldExtension.mdx b/content/docs/references/ai/field-extensions/AIFieldExtension.mdx new file mode 100644 index 0000000000..6b3017c69d --- /dev/null +++ b/content/docs/references/ai/field-extensions/AIFieldExtension.mdx @@ -0,0 +1,18 @@ +--- +title: AIFieldExtension +description: AIFieldExtension Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ai_assistant.vectorIndexed** | `boolean` | optional | | +| **ai_assistant.embeddingModel** | `string` | optional | | +| **ai_assistant.chunkSize** | `number` | optional | | +| **ai_assistant.chunkOverlap** | `number` | optional | | +| **ai_assistant.autoSummarize** | `boolean` | optional | | +| **ai_assistant.summaryModel** | `string` | optional | | +| **ai_assistant.summaryMaxLength** | `number` | optional | | +| **ai_assistant.sentimentAnalysis** | `boolean` | optional | | +| **ai_assistant.sentimentField** | `string` | optional | | diff --git a/content/docs/references/ai/field-extensions/meta.json b/content/docs/references/ai/field-extensions/meta.json new file mode 100644 index 0000000000..c03de0c2c2 --- /dev/null +++ b/content/docs/references/ai/field-extensions/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Field Extensions" +} \ No newline at end of file diff --git a/content/docs/references/ai/meta.json b/content/docs/references/ai/meta.json index 460ee83f8d..0a7ff13385 100644 --- a/content/docs/references/ai/meta.json +++ b/content/docs/references/ai/meta.json @@ -5,8 +5,10 @@ "agent", "conversation", "cost", + "field-extensions", "model-registry", "nlq", + "object-extensions", "orchestration", "predictive", "rag-pipeline" diff --git a/content/docs/references/ai/object-extensions/AIObjectExtension.mdx b/content/docs/references/ai/object-extensions/AIObjectExtension.mdx new file mode 100644 index 0000000000..f0074dee0d --- /dev/null +++ b/content/docs/references/ai/object-extensions/AIObjectExtension.mdx @@ -0,0 +1,24 @@ +--- +title: AIObjectExtension +description: AIObjectExtension Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ai_assistant.enableRAG** | `boolean` | optional | | +| **ai_assistant.contextFields** | `string[]` | optional | | +| **ai_assistant.vectorIndex** | `string` | optional | | +| **ai_assistant.embeddingModel** | `string` | optional | | +| **ai_assistant.agentEnabled** | `boolean` | optional | | +| **ai_assistant.agentName** | `string` | optional | | +| **ai_assistant.agentTriggers** | `string[]` | optional | | +| **ai_assistant.predictiveEnabled** | `boolean` | optional | | +| **ai_assistant.predictiveModels** | `any[]` | optional | | +| **ai_assistant.autoClassification** | `boolean` | optional | | +| **ai_assistant.classificationField** | `string` | optional | | +| **ai_assistant.classificationModel** | `string` | optional | | +| **ai_assistant.classificationPrompt** | `string` | optional | | +| **ai_assistant.dataQualityEnabled** | `boolean` | optional | | +| **ai_assistant.dataQualityRules** | `any[]` | optional | | diff --git a/content/docs/references/ai/object-extensions/meta.json b/content/docs/references/ai/object-extensions/meta.json new file mode 100644 index 0000000000..537170ca93 --- /dev/null +++ b/content/docs/references/ai/object-extensions/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Object Extensions" +} \ No newline at end of file diff --git a/content/docs/references/data/field/Field.mdx b/content/docs/references/data/field/Field.mdx index 71a679c1c8..4d415927c9 100644 --- a/content/docs/references/data/field/Field.mdx +++ b/content/docs/references/data/field/Field.mdx @@ -55,3 +55,4 @@ description: Field Schema Reference | **encryption** | `boolean` | optional | Encrypt at rest | | **index** | `boolean` | optional | Create standard database index | | **externalId** | `boolean` | optional | Is external ID for upsert operations | +| **extensions** | `Record[] \| Record>>` | optional | Custom extension properties from plugins and modules | diff --git a/content/docs/references/data/object/Object.mdx b/content/docs/references/data/object/Object.mdx index d9aca8c564..b353fc860e 100644 --- a/content/docs/references/data/object/Object.mdx +++ b/content/docs/references/data/object/Object.mdx @@ -25,3 +25,4 @@ description: Object Schema Reference | **compactLayout** | `string[]` | optional | Primary fields for hover/cards/lookups | | **search** | `object` | optional | Search engine configuration | | **enable** | `object` | optional | Enabled system features modules | +| **extensions** | `Record[] \| Record>>` | optional | Custom extension properties from plugins and modules | diff --git a/content/docs/references/system/extension/ExtensionDefinition.mdx b/content/docs/references/system/extension/ExtensionDefinition.mdx new file mode 100644 index 0000000000..736d02e49b --- /dev/null +++ b/content/docs/references/system/extension/ExtensionDefinition.mdx @@ -0,0 +1,19 @@ +--- +title: ExtensionDefinition +description: ExtensionDefinition Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **key** | `string` | ✅ | Fully qualified extension key (e.g., "ai_assistant.vectorIndexed") | +| **pluginId** | `string` | ✅ | ID of the plugin providing this extension | +| **label** | `string` | ✅ | Display label for the extension property | +| **description** | `string` | optional | Detailed description of the extension | +| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array' \| 'any'>` | ✅ | Expected data type for this extension | +| **default** | `any` | optional | Default value for this extension | +| **appliesTo** | `Enum<'object' \| 'field' \| 'view' \| 'app' \| 'dashboard' \| 'report' \| 'action' \| 'workflow'>[]` | ✅ | Metadata types this extension can be applied to | +| **fieldTypes** | `string[]` | optional | Applicable field types (for field extensions) | +| **required** | `boolean` | optional | Whether this extension is required | +| **schema** | `any` | optional | JSON Schema for advanced validation | diff --git a/content/docs/references/system/extension/ExtensionRegistry.mdx b/content/docs/references/system/extension/ExtensionRegistry.mdx new file mode 100644 index 0000000000..ecf7bf4570 --- /dev/null +++ b/content/docs/references/system/extension/ExtensionRegistry.mdx @@ -0,0 +1,10 @@ +--- +title: ExtensionRegistry +description: ExtensionRegistry Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **extensions** | `Record` | ✅ | Registry of available extension definitions | diff --git a/content/docs/references/system/extension/ExtensionValue.mdx b/content/docs/references/system/extension/ExtensionValue.mdx new file mode 100644 index 0000000000..18a3566c6a --- /dev/null +++ b/content/docs/references/system/extension/ExtensionValue.mdx @@ -0,0 +1,5 @@ +--- +title: ExtensionValue +description: ExtensionValue Schema Reference +--- + diff --git a/content/docs/references/system/extension/ExtensionsMap.mdx b/content/docs/references/system/extension/ExtensionsMap.mdx new file mode 100644 index 0000000000..4e17d836f2 --- /dev/null +++ b/content/docs/references/system/extension/ExtensionsMap.mdx @@ -0,0 +1,7 @@ +--- +title: ExtensionsMap +description: Custom extension properties from plugins and modules +--- + +Custom extension properties from plugins and modules + diff --git a/content/docs/references/system/extension/meta.json b/content/docs/references/system/extension/meta.json new file mode 100644 index 0000000000..ee2810c4bc --- /dev/null +++ b/content/docs/references/system/extension/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Extension" +} \ No newline at end of file diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 5e0498ff1c..03d53b4c1e 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -4,6 +4,7 @@ "pages": [ "audit", "events", + "extension", "job", "translation" ] diff --git a/examples/ai-customer-service/README.md b/examples/ai-customer-service/README.md new file mode 100644 index 0000000000..8987b42067 --- /dev/null +++ b/examples/ai-customer-service/README.md @@ -0,0 +1,33 @@ +# AI-Powered Customer Service Example + +This example demonstrates how to use ObjectStack's metadata extension mechanism to build an AI-powered customer service system. + +## Features + +### Field-Level AI Extensions +- **Vector Indexing**: Enable semantic search on text fields +- **Auto-Summarization**: Automatically generate summaries of long descriptions +- **Sentiment Analysis**: Analyze sentiment of customer feedback + +### Object-Level AI Extensions +- **RAG (Retrieval-Augmented Generation)**: Enable context-aware AI responses +- **AI Agent Integration**: Automatic agent assistance on create/update +- **Auto-Classification**: Automatically categorize inquiries +- **Data Quality Checks**: AI-powered data validation + +## Objects + +1. **Customer Inquiry** - Support tickets with full AI assistance +2. **Knowledge Article** - RAG-enabled knowledge base + +## Running the Example + +```bash +cd examples/ai-customer-service +npm install +npm run example +``` + +## Usage + +See `index.ts` for the complete implementation. diff --git a/examples/ai-customer-service/index.ts b/examples/ai-customer-service/index.ts new file mode 100644 index 0000000000..69b6f7c4bf --- /dev/null +++ b/examples/ai-customer-service/index.ts @@ -0,0 +1,44 @@ +/** + * AI-Powered Customer Service Example + * + * Demonstrates ObjectStack metadata extensions for AI capabilities + */ + +import { ObjectSchema, Extension } from '@objectstack/spec'; + +// Customer Inquiry Object with AI extensions +export const customerInquiryObject = { + name: 'customer_inquiry', + label: 'Customer Inquiry', + fields: { + title: { + name: 'title', + label: 'Title', + type: 'text' as const, + required: true, + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }, + }, + description: { + name: 'description', + label: 'Description', + type: 'textarea' as const, + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.autoSummarize': true, + }, + }, + }, + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'description'], + 'ai_assistant.agentEnabled': true, + }, +}; + +// Validate +const validated = ObjectSchema.parse(customerInquiryObject); +console.log('✓ Schema validated with AI extensions'); +console.log('RAG Enabled:', Extension.get(validated.extensions, 'ai_assistant.enableRAG')); diff --git a/packages/spec/json-schema/ai/AIFieldExtension.json b/packages/spec/json-schema/ai/AIFieldExtension.json new file mode 100644 index 0000000000..e6e4fb0b1c --- /dev/null +++ b/packages/spec/json-schema/ai/AIFieldExtension.json @@ -0,0 +1,39 @@ +{ + "$ref": "#/definitions/AIFieldExtension", + "definitions": { + "AIFieldExtension": { + "type": "object", + "properties": { + "ai_assistant.vectorIndexed": { + "type": "boolean" + }, + "ai_assistant.embeddingModel": { + "type": "string" + }, + "ai_assistant.chunkSize": { + "type": "number" + }, + "ai_assistant.chunkOverlap": { + "type": "number" + }, + "ai_assistant.autoSummarize": { + "type": "boolean" + }, + "ai_assistant.summaryModel": { + "type": "string" + }, + "ai_assistant.summaryMaxLength": { + "type": "number" + }, + "ai_assistant.sentimentAnalysis": { + "type": "boolean" + }, + "ai_assistant.sentimentField": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/ai/AIObjectExtension.json b/packages/spec/json-schema/ai/AIObjectExtension.json new file mode 100644 index 0000000000..782487a6a0 --- /dev/null +++ b/packages/spec/json-schema/ai/AIObjectExtension.json @@ -0,0 +1,63 @@ +{ + "$ref": "#/definitions/AIObjectExtension", + "definitions": { + "AIObjectExtension": { + "type": "object", + "properties": { + "ai_assistant.enableRAG": { + "type": "boolean" + }, + "ai_assistant.contextFields": { + "type": "array", + "items": { + "type": "string" + } + }, + "ai_assistant.vectorIndex": { + "type": "string" + }, + "ai_assistant.embeddingModel": { + "type": "string" + }, + "ai_assistant.agentEnabled": { + "type": "boolean" + }, + "ai_assistant.agentName": { + "type": "string" + }, + "ai_assistant.agentTriggers": { + "type": "array", + "items": { + "type": "string" + } + }, + "ai_assistant.predictiveEnabled": { + "type": "boolean" + }, + "ai_assistant.predictiveModels": { + "type": "array" + }, + "ai_assistant.autoClassification": { + "type": "boolean" + }, + "ai_assistant.classificationField": { + "type": "string" + }, + "ai_assistant.classificationModel": { + "type": "string" + }, + "ai_assistant.classificationPrompt": { + "type": "string" + }, + "ai_assistant.dataQualityEnabled": { + "type": "boolean" + }, + "ai_assistant.dataQualityRules": { + "type": "array" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/Field.json b/packages/spec/json-schema/data/Field.json index 24c29ec550..9e82fe85ee 100644 --- a/packages/spec/json-schema/data/Field.json +++ b/packages/spec/json-schema/data/Field.json @@ -357,6 +357,81 @@ "type": "boolean", "default": false, "description": "Is external ID for upsert operations" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ diff --git a/packages/spec/json-schema/data/Object.json b/packages/spec/json-schema/data/Object.json index 499a0bcc5d..3104f641d2 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -414,6 +414,81 @@ "type": "boolean", "default": false, "description": "Is external ID for upsert operations" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ @@ -2016,6 +2091,81 @@ }, "additionalProperties": false, "description": "Enabled system features modules" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ diff --git a/packages/spec/json-schema/hub/ComposerResponse.json b/packages/spec/json-schema/hub/ComposerResponse.json index 687c4378f6..d1d758d4f2 100644 --- a/packages/spec/json-schema/hub/ComposerResponse.json +++ b/packages/spec/json-schema/hub/ComposerResponse.json @@ -469,6 +469,81 @@ "type": "boolean", "default": false, "description": "Is external ID for upsert operations" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ @@ -2071,6 +2146,81 @@ }, "additionalProperties": false, "description": "Enabled system features modules" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ diff --git a/packages/spec/json-schema/kernel/Manifest.json b/packages/spec/json-schema/kernel/Manifest.json index bd11adbbe9..dd69bb9008 100644 --- a/packages/spec/json-schema/kernel/Manifest.json +++ b/packages/spec/json-schema/kernel/Manifest.json @@ -463,6 +463,81 @@ "type": "boolean", "default": false, "description": "Is external ID for upsert operations" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ @@ -2065,6 +2140,81 @@ }, "additionalProperties": false, "description": "Enabled system features modules" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ diff --git a/packages/spec/json-schema/system/ExtensionDefinition.json b/packages/spec/json-schema/system/ExtensionDefinition.json new file mode 100644 index 0000000000..22c6ad164a --- /dev/null +++ b/packages/spec/json-schema/system/ExtensionDefinition.json @@ -0,0 +1,83 @@ +{ + "$ref": "#/definitions/ExtensionDefinition", + "definitions": { + "ExtensionDefinition": { + "type": "object", + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*\\.[a-zA-Z][a-zA-Z0-9_]*$", + "description": "Fully qualified extension key (e.g., \"ai_assistant.vectorIndexed\")" + }, + "pluginId": { + "type": "string", + "description": "ID of the plugin providing this extension" + }, + "label": { + "type": "string", + "description": "Display label for the extension property" + }, + "description": { + "type": "string", + "description": "Detailed description of the extension" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "object", + "array", + "any" + ], + "description": "Expected data type for this extension" + }, + "default": { + "description": "Default value for this extension" + }, + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "object", + "field", + "view", + "app", + "dashboard", + "report", + "action", + "workflow" + ] + }, + "description": "Metadata types this extension can be applied to" + }, + "fieldTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Applicable field types (for field extensions)" + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this extension is required" + }, + "schema": { + "description": "JSON Schema for advanced validation" + } + }, + "required": [ + "key", + "pluginId", + "label", + "type", + "appliesTo" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ExtensionRegistry.json b/packages/spec/json-schema/system/ExtensionRegistry.json new file mode 100644 index 0000000000..9b5e1c0703 --- /dev/null +++ b/packages/spec/json-schema/system/ExtensionRegistry.json @@ -0,0 +1,96 @@ +{ + "$ref": "#/definitions/ExtensionRegistry", + "definitions": { + "ExtensionRegistry": { + "type": "object", + "properties": { + "extensions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*\\.[a-zA-Z][a-zA-Z0-9_]*$", + "description": "Fully qualified extension key (e.g., \"ai_assistant.vectorIndexed\")" + }, + "pluginId": { + "type": "string", + "description": "ID of the plugin providing this extension" + }, + "label": { + "type": "string", + "description": "Display label for the extension property" + }, + "description": { + "type": "string", + "description": "Detailed description of the extension" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "object", + "array", + "any" + ], + "description": "Expected data type for this extension" + }, + "default": { + "description": "Default value for this extension" + }, + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "object", + "field", + "view", + "app", + "dashboard", + "report", + "action", + "workflow" + ] + }, + "description": "Metadata types this extension can be applied to" + }, + "fieldTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Applicable field types (for field extensions)" + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this extension is required" + }, + "schema": { + "description": "JSON Schema for advanced validation" + } + }, + "required": [ + "key", + "pluginId", + "label", + "type", + "appliesTo" + ], + "additionalProperties": false + }, + "description": "Registry of available extension definitions" + } + }, + "required": [ + "extensions" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ExtensionValue.json b/packages/spec/json-schema/system/ExtensionValue.json new file mode 100644 index 0000000000..618d0f36df --- /dev/null +++ b/packages/spec/json-schema/system/ExtensionValue.json @@ -0,0 +1,30 @@ +{ + "$ref": "#/definitions/ExtensionValue", + "definitions": { + "ExtensionValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ExtensionsMap.json b/packages/spec/json-schema/system/ExtensionsMap.json new file mode 100644 index 0000000000..db1a6a06ed --- /dev/null +++ b/packages/spec/json-schema/system/ExtensionsMap.json @@ -0,0 +1,88 @@ +{ + "$ref": "#/definitions/ExtensionsMap", + "definitions": { + "ExtensionsMap": { + "anyOf": [ + { + "not": {} + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + } + } + ], + "description": "Custom extension properties from plugins and modules" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/ui/FieldWidgetProps.json b/packages/spec/json-schema/ui/FieldWidgetProps.json index a970401fbe..ec9422d9a2 100644 --- a/packages/spec/json-schema/ui/FieldWidgetProps.json +++ b/packages/spec/json-schema/ui/FieldWidgetProps.json @@ -377,6 +377,81 @@ "type": "boolean", "default": false, "description": "Is external ID for upsert operations" + }, + "extensions": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ], + "description": "Extension value (string, number, boolean, object, or array)" + }, + "description": "Custom extension properties from plugins and modules" } }, "required": [ diff --git a/packages/spec/src/ai/extensions.test.ts b/packages/spec/src/ai/extensions.test.ts new file mode 100644 index 0000000000..001e74b3c5 --- /dev/null +++ b/packages/spec/src/ai/extensions.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; +import { + AIFieldExtensions, + AIFieldExtensionSchema, + type AIFieldExtension, +} from './field-extensions.zod'; +import { + AIObjectExtensions, + AIObjectExtensionSchema, + type AIObjectExtension, +} from './object-extensions.zod'; +import { FieldSchema, type Field } from '../data/field.zod'; +import { ObjectSchema, type ServiceObject } from '../data/object.zod'; +import { Extension } from '../system/extension.zod'; + +describe('AI Field Extensions', () => { + describe('Extension Definitions', () => { + it('should have valid vector indexed extension', () => { + const ext = AIFieldExtensions.VectorIndexedExtension; + expect(ext.key).toBe('ai_assistant.vectorIndexed'); + expect(ext.pluginId).toBe('ai_assistant'); + expect(ext.type).toBe('boolean'); + expect(ext.appliesTo).toContain('field'); + }); + + it('should have valid embedding model extension', () => { + const ext = AIFieldExtensions.EmbeddingModelExtension; + expect(ext.key).toBe('ai_assistant.embeddingModel'); + expect(ext.default).toBe('text-embedding-3-small'); + }); + + it('should have valid auto-summarize extension', () => { + const ext = AIFieldExtensions.AutoSummarizeExtension; + expect(ext.key).toBe('ai_assistant.autoSummarize'); + expect(ext.fieldTypes).toContain('textarea'); + }); + }); + + describe('Field with AI Extensions', () => { + it('should accept field with vector indexing enabled', () => { + const field: Field = { + name: 'description', + label: 'Description', + type: 'textarea', + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + 'ai_assistant.chunkOverlap': 50, + }, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + + const vectorIndexed = Extension.get(field.extensions, 'ai_assistant.vectorIndexed'); + expect(vectorIndexed).toBe(true); + + const embeddingModel = Extension.get(field.extensions, 'ai_assistant.embeddingModel'); + expect(embeddingModel).toBe('text-embedding-3-small'); + }); + + it('should accept field with auto-summarization', () => { + const field: Field = { + name: 'case_notes', + label: 'Case Notes', + type: 'textarea', + extensions: { + 'ai_assistant.autoSummarize': true, + 'ai_assistant.summaryModel': 'gpt-4o-mini', + 'ai_assistant.summaryMaxLength': 200, + }, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + + const autoSummarize = Extension.get(field.extensions, 'ai_assistant.autoSummarize'); + expect(autoSummarize).toBe(true); + }); + + it('should accept field with sentiment analysis', () => { + const field: Field = { + name: 'customer_feedback', + label: 'Customer Feedback', + type: 'textarea', + extensions: { + 'ai_assistant.sentimentAnalysis': true, + 'ai_assistant.sentimentField': 'sentiment_score', + }, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); + + it('should work with Extension helper functions', () => { + let extensions = Extension.set(undefined, 'ai_assistant.vectorIndexed', true); + extensions = Extension.set(extensions, 'ai_assistant.embeddingModel', 'text-embedding-3-small'); + extensions = Extension.set(extensions, 'ai_assistant.chunkSize', 512); + + expect(Extension.has(extensions, 'ai_assistant.vectorIndexed')).toBe(true); + expect(Extension.get(extensions, 'ai_assistant.chunkSize')).toBe(512); + }); + }); + + describe('AI Field Extension Schema', () => { + it('should validate AI field extensions', () => { + const extensions: AIFieldExtension = { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + }; + + expect(() => AIFieldExtensionSchema.parse(extensions)).not.toThrow(); + }); + + it('should accept partial extensions', () => { + const extensions: AIFieldExtension = { + 'ai_assistant.vectorIndexed': true, + }; + + expect(() => AIFieldExtensionSchema.parse(extensions)).not.toThrow(); + }); + + it('should accept empty extensions', () => { + expect(() => AIFieldExtensionSchema.parse({})).not.toThrow(); + }); + }); +}); + +describe('AI Object Extensions', () => { + describe('Extension Definitions', () => { + it('should have valid enable RAG extension', () => { + const ext = AIObjectExtensions.EnableRAGExtension; + expect(ext.key).toBe('ai_assistant.enableRAG'); + expect(ext.pluginId).toBe('ai_assistant'); + expect(ext.type).toBe('boolean'); + expect(ext.appliesTo).toContain('object'); + }); + + it('should have valid agent enabled extension', () => { + const ext = AIObjectExtensions.AgentEnabledExtension; + expect(ext.key).toBe('ai_assistant.agentEnabled'); + expect(ext.default).toBe(false); + }); + + it('should have valid predictive enabled extension', () => { + const ext = AIObjectExtensions.PredictiveEnabledExtension; + expect(ext.key).toBe('ai_assistant.predictiveEnabled'); + }); + }); + + describe('Object with AI Extensions', () => { + it('should accept object with RAG enabled', () => { + const object: ServiceObject = { + name: 'knowledge_article', + label: 'Knowledge Article', + fields: { + title: { type: 'text', label: 'Title' }, + content: { type: 'textarea', label: 'Content' }, + }, + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'content'], + 'ai_assistant.vectorIndex': 'knowledge_base_v1', + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + + const enableRAG = Extension.get(object.extensions, 'ai_assistant.enableRAG'); + expect(enableRAG).toBe(true); + + const contextFields = Extension.get(object.extensions, 'ai_assistant.contextFields'); + expect(contextFields).toEqual(['title', 'content']); + }); + + it('should accept object with AI agent', () => { + const object: ServiceObject = { + name: 'support_case', + label: 'Support Case', + fields: {}, + extensions: { + 'ai_assistant.agentEnabled': true, + 'ai_assistant.agentName': 'support_assistant', + 'ai_assistant.agentTriggers': ['onCreate', 'onUpdate'], + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should accept object with predictive analytics', () => { + const object: ServiceObject = { + name: 'opportunity', + label: 'Opportunity', + fields: {}, + extensions: { + 'ai_assistant.predictiveEnabled': true, + 'ai_assistant.predictiveModels': [ + { + name: 'win_probability', + type: 'classification', + targetField: 'stage', + features: ['amount', 'duration', 'competitor_count'], + }, + ], + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should accept object with auto-classification', () => { + const object: ServiceObject = { + name: 'email', + label: 'Email', + fields: {}, + extensions: { + 'ai_assistant.autoClassification': true, + 'ai_assistant.classificationField': 'category', + 'ai_assistant.classificationModel': 'gpt-4o-mini', + 'ai_assistant.classificationPrompt': 'Classify this email into: Support, Sales, or General', + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should accept object with data quality checks', () => { + const object: ServiceObject = { + name: 'account', + label: 'Account', + fields: {}, + extensions: { + 'ai_assistant.dataQualityEnabled': true, + 'ai_assistant.dataQualityRules': [ + { + type: 'completeness', + fields: ['name', 'email', 'phone'], + threshold: 0.8, + }, + { + type: 'consistency', + checkDuplicates: true, + }, + ], + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should work with Extension helper functions', () => { + let extensions = Extension.set(undefined, 'ai_assistant.enableRAG', true); + extensions = Extension.set(extensions, 'ai_assistant.contextFields', ['title', 'content']); + extensions = Extension.set(extensions, 'ai_assistant.vectorIndex', 'knowledge_v1'); + + expect(Extension.has(extensions, 'ai_assistant.enableRAG')).toBe(true); + expect(Extension.get(extensions, 'ai_assistant.vectorIndex')).toBe('knowledge_v1'); + }); + }); + + describe('AI Object Extension Schema', () => { + it('should validate AI object extensions', () => { + const extensions: AIObjectExtension = { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'content'], + 'ai_assistant.vectorIndex': 'knowledge_v1', + }; + + expect(() => AIObjectExtensionSchema.parse(extensions)).not.toThrow(); + }); + + it('should accept partial extensions', () => { + const extensions: AIObjectExtension = { + 'ai_assistant.enableRAG': true, + }; + + expect(() => AIObjectExtensionSchema.parse(extensions)).not.toThrow(); + }); + + it('should accept empty extensions', () => { + expect(() => AIObjectExtensionSchema.parse({})).not.toThrow(); + }); + }); +}); + +describe('Complete Integration Example', () => { + it('should create a complete AI-powered object with field extensions', () => { + const object: ServiceObject = { + name: 'customer_inquiry', + label: 'Customer Inquiry', + pluralLabel: 'Customer Inquiries', + description: 'Customer support inquiries with AI assistance', + icon: 'message-square', + fields: { + title: { + name: 'title', + label: 'Title', + type: 'text', + required: true, + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }, + }, + description: { + name: 'description', + label: 'Description', + type: 'textarea', + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + 'ai_assistant.autoSummarize': true, + 'ai_assistant.summaryModel': 'gpt-4o-mini', + }, + }, + customer_feedback: { + name: 'customer_feedback', + label: 'Customer Feedback', + type: 'textarea', + extensions: { + 'ai_assistant.sentimentAnalysis': true, + 'ai_assistant.sentimentField': 'sentiment_score', + }, + }, + sentiment_score: { + name: 'sentiment_score', + label: 'Sentiment Score', + type: 'number', + readonly: true, + }, + }, + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['title', 'description'], + 'ai_assistant.vectorIndex': 'customer_inquiries_v1', + 'ai_assistant.agentEnabled': true, + 'ai_assistant.agentName': 'support_assistant', + 'ai_assistant.agentTriggers': ['onCreate', 'onUpdate'], + 'ai_assistant.autoClassification': true, + 'ai_assistant.classificationField': 'category', + 'ai_assistant.classificationModel': 'gpt-4o-mini', + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + + const parsed = ObjectSchema.parse(object); + + // Verify object-level extensions + expect(Extension.get(parsed.extensions, 'ai_assistant.enableRAG')).toBe(true); + expect(Extension.get(parsed.extensions, 'ai_assistant.agentEnabled')).toBe(true); + + // Verify field-level extensions + const titleField = parsed.fields.title; + expect(Extension.get(titleField.extensions, 'ai_assistant.vectorIndexed')).toBe(true); + + const descField = parsed.fields.description; + expect(Extension.get(descField.extensions, 'ai_assistant.autoSummarize')).toBe(true); + + const feedbackField = parsed.fields.customer_feedback; + expect(Extension.get(feedbackField.extensions, 'ai_assistant.sentimentAnalysis')).toBe(true); + }); +}); diff --git a/packages/spec/src/ai/field-extensions.zod.ts b/packages/spec/src/ai/field-extensions.zod.ts new file mode 100644 index 0000000000..78fce1afee --- /dev/null +++ b/packages/spec/src/ai/field-extensions.zod.ts @@ -0,0 +1,227 @@ +import { z } from 'zod'; +import { type ExtensionDefinition } from '../system/extension.zod'; + +/** + * AI Field Extensions + * + * Defines AI-specific extension properties for fields. + * These extensions enable vector indexing, semantic search, and RAG capabilities. + * + * Usage: Add to field.extensions with 'ai_assistant.' prefix + */ + +/** + * Vector Indexing Extension + * + * Enables this field to be indexed for vector/semantic search. + * + * @example + * { + * name: 'description', + * type: 'textarea', + * extensions: { + * 'ai_assistant.vectorIndexed': true, + * 'ai_assistant.embeddingModel': 'text-embedding-3-small', + * 'ai_assistant.chunkSize': 512, + * 'ai_assistant.chunkOverlap': 50 + * } + * } + */ +export const VectorIndexedExtension: ExtensionDefinition = { + key: 'ai_assistant.vectorIndexed', + pluginId: 'ai_assistant', + label: 'Vector Indexed', + description: 'Enable vector indexing for semantic search on this field', + type: 'boolean', + default: false, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Embedding Model Extension + * + * Specifies which embedding model to use for this field. + */ +export const EmbeddingModelExtension: ExtensionDefinition = { + key: 'ai_assistant.embeddingModel', + pluginId: 'ai_assistant', + label: 'Embedding Model', + description: 'The embedding model to use for vector indexing', + type: 'string', + default: 'text-embedding-3-small', + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Chunk Size Extension + * + * Specifies the chunk size for text splitting during vector indexing. + */ +export const ChunkSizeExtension: ExtensionDefinition = { + key: 'ai_assistant.chunkSize', + pluginId: 'ai_assistant', + label: 'Chunk Size', + description: 'Maximum chunk size for text splitting (in tokens)', + type: 'number', + default: 512, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Chunk Overlap Extension + * + * Specifies the overlap between chunks for better context preservation. + */ +export const ChunkOverlapExtension: ExtensionDefinition = { + key: 'ai_assistant.chunkOverlap', + pluginId: 'ai_assistant', + label: 'Chunk Overlap', + description: 'Overlap size between consecutive chunks (in tokens)', + type: 'number', + default: 50, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Auto-summarization Extension + * + * Enables automatic summarization of long text fields. + * + * @example + * { + * name: 'case_notes', + * type: 'textarea', + * extensions: { + * 'ai_assistant.autoSummarize': true, + * 'ai_assistant.summaryModel': 'gpt-4o-mini', + * 'ai_assistant.summaryMaxLength': 200 + * } + * } + */ +export const AutoSummarizeExtension: ExtensionDefinition = { + key: 'ai_assistant.autoSummarize', + pluginId: 'ai_assistant', + label: 'Auto Summarize', + description: 'Automatically generate summaries of long text content', + type: 'boolean', + default: false, + appliesTo: ['field'], + fieldTypes: ['textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Summary Model Extension + */ +export const SummaryModelExtension: ExtensionDefinition = { + key: 'ai_assistant.summaryModel', + pluginId: 'ai_assistant', + label: 'Summary Model', + description: 'The LLM model to use for summarization', + type: 'string', + default: 'gpt-4o-mini', + appliesTo: ['field'], + fieldTypes: ['textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Summary Max Length Extension + */ +export const SummaryMaxLengthExtension: ExtensionDefinition = { + key: 'ai_assistant.summaryMaxLength', + pluginId: 'ai_assistant', + label: 'Summary Max Length', + description: 'Maximum length of generated summary (in characters)', + type: 'number', + default: 200, + appliesTo: ['field'], + fieldTypes: ['textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Sentiment Analysis Extension + * + * Enables sentiment analysis on text fields. + * + * @example + * { + * name: 'customer_feedback', + * type: 'textarea', + * extensions: { + * 'ai_assistant.sentimentAnalysis': true, + * 'ai_assistant.sentimentField': 'sentiment_score' + * } + * } + */ +export const SentimentAnalysisExtension: ExtensionDefinition = { + key: 'ai_assistant.sentimentAnalysis', + pluginId: 'ai_assistant', + label: 'Sentiment Analysis', + description: 'Analyze sentiment of text content', + type: 'boolean', + default: false, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * Sentiment Field Extension + * + * Specifies the field where sentiment score should be stored. + */ +export const SentimentFieldExtension: ExtensionDefinition = { + key: 'ai_assistant.sentimentField', + pluginId: 'ai_assistant', + label: 'Sentiment Field', + description: 'Field name to store computed sentiment score', + type: 'string', + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown', 'html', 'richtext'], + required: false, +}; + +/** + * AI Field Extensions Registry + */ +export const AIFieldExtensions = { + VectorIndexedExtension, + EmbeddingModelExtension, + ChunkSizeExtension, + ChunkOverlapExtension, + AutoSummarizeExtension, + SummaryModelExtension, + SummaryMaxLengthExtension, + SentimentAnalysisExtension, + SentimentFieldExtension, +}; + +/** + * AI Field Extension Schema + * + * Zod schema for AI field extensions. + * This can be used for runtime validation. + */ +export const AIFieldExtensionSchema = z.object({ + 'ai_assistant.vectorIndexed': z.boolean(), + 'ai_assistant.embeddingModel': z.string(), + 'ai_assistant.chunkSize': z.number(), + 'ai_assistant.chunkOverlap': z.number(), + 'ai_assistant.autoSummarize': z.boolean(), + 'ai_assistant.summaryModel': z.string(), + 'ai_assistant.summaryMaxLength': z.number(), + 'ai_assistant.sentimentAnalysis': z.boolean(), + 'ai_assistant.sentimentField': z.string(), +}).partial(); + +export type AIFieldExtension = z.infer; diff --git a/packages/spec/src/ai/index.ts b/packages/spec/src/ai/index.ts index f3665c299d..1c58fdd6e8 100644 --- a/packages/spec/src/ai/index.ts +++ b/packages/spec/src/ai/index.ts @@ -10,6 +10,7 @@ * - Predictive Analytics * - Conversation Memory & Token Management * - Cost Tracking & Budget Management + * - AI Extensions for Objects and Fields */ export * from './agent.zod'; @@ -20,3 +21,5 @@ export * from './orchestration.zod'; export * from './predictive.zod'; export * from './conversation.zod'; export * from './cost.zod'; +export * from './field-extensions.zod'; +export * from './object-extensions.zod'; diff --git a/packages/spec/src/ai/object-extensions.zod.ts b/packages/spec/src/ai/object-extensions.zod.ts new file mode 100644 index 0000000000..4ca9d92fd9 --- /dev/null +++ b/packages/spec/src/ai/object-extensions.zod.ts @@ -0,0 +1,349 @@ +import { z } from 'zod'; +import { type ExtensionDefinition } from '../system/extension.zod'; + +/** + * AI Object Extensions + * + * Defines AI-specific extension properties for objects. + * These extensions enable RAG, predictive analytics, and intelligent automation. + * + * Usage: Add to object.extensions with 'ai_assistant.' prefix + */ + +/** + * Enable RAG Extension + * + * Enables Retrieval-Augmented Generation for this object. + * When enabled, records from this object can be used as context for AI agents. + * + * @example + * { + * name: 'knowledge_article', + * fields: { ... }, + * extensions: { + * 'ai_assistant.enableRAG': true, + * 'ai_assistant.contextFields': ['title', 'content', 'summary'], + * 'ai_assistant.vectorIndex': 'knowledge_base_v1', + * 'ai_assistant.embeddingModel': 'text-embedding-3-small' + * } + * } + */ +export const EnableRAGExtension: ExtensionDefinition = { + key: 'ai_assistant.enableRAG', + pluginId: 'ai_assistant', + label: 'Enable RAG', + description: 'Enable Retrieval-Augmented Generation for this object', + type: 'boolean', + default: false, + appliesTo: ['object'], + required: false, +}; + +/** + * Context Fields Extension + * + * Specifies which fields should be included in RAG context. + */ +export const ContextFieldsExtension: ExtensionDefinition = { + key: 'ai_assistant.contextFields', + pluginId: 'ai_assistant', + label: 'Context Fields', + description: 'Fields to include in RAG context (array of field names)', + type: 'array', + appliesTo: ['object'], + required: false, +}; + +/** + * Vector Index Extension + * + * Specifies the vector index name for this object. + */ +export const VectorIndexExtension: ExtensionDefinition = { + key: 'ai_assistant.vectorIndex', + pluginId: 'ai_assistant', + label: 'Vector Index', + description: 'Name of the vector index for this object', + type: 'string', + appliesTo: ['object'], + required: false, +}; + +/** + * Object Embedding Model Extension + * + * Specifies the embedding model for object-level RAG. + */ +export const ObjectEmbeddingModelExtension: ExtensionDefinition = { + key: 'ai_assistant.embeddingModel', + pluginId: 'ai_assistant', + label: 'Embedding Model', + description: 'The embedding model to use for this object', + type: 'string', + default: 'text-embedding-3-small', + appliesTo: ['object'], + required: false, +}; + +/** + * AI Agent Extension + * + * Associates an AI agent with this object for intelligent assistance. + * + * @example + * { + * name: 'support_case', + * fields: { ... }, + * extensions: { + * 'ai_assistant.agentEnabled': true, + * 'ai_assistant.agentName': 'support_assistant', + * 'ai_assistant.agentTriggers': ['onCreate', 'onUpdate'] + * } + * } + */ +export const AgentEnabledExtension: ExtensionDefinition = { + key: 'ai_assistant.agentEnabled', + pluginId: 'ai_assistant', + label: 'Agent Enabled', + description: 'Enable AI agent assistance for this object', + type: 'boolean', + default: false, + appliesTo: ['object'], + required: false, +}; + +/** + * Agent Name Extension + */ +export const AgentNameExtension: ExtensionDefinition = { + key: 'ai_assistant.agentName', + pluginId: 'ai_assistant', + label: 'Agent Name', + description: 'The AI agent to use for this object', + type: 'string', + appliesTo: ['object'], + required: false, +}; + +/** + * Agent Triggers Extension + */ +export const AgentTriggersExtension: ExtensionDefinition = { + key: 'ai_assistant.agentTriggers', + pluginId: 'ai_assistant', + label: 'Agent Triggers', + description: 'Events that trigger the AI agent (e.g., onCreate, onUpdate)', + type: 'array', + appliesTo: ['object'], + required: false, +}; + +/** + * Predictive Analytics Extension + * + * Enables predictive models for this object. + * + * @example + * { + * name: 'opportunity', + * fields: { ... }, + * extensions: { + * 'ai_assistant.predictiveEnabled': true, + * 'ai_assistant.predictiveModels': [ + * { + * name: 'win_probability', + * type: 'classification', + * targetField: 'stage', + * features: ['amount', 'duration', 'competitor_count'] + * } + * ] + * } + * } + */ +export const PredictiveEnabledExtension: ExtensionDefinition = { + key: 'ai_assistant.predictiveEnabled', + pluginId: 'ai_assistant', + label: 'Predictive Enabled', + description: 'Enable predictive analytics for this object', + type: 'boolean', + default: false, + appliesTo: ['object'], + required: false, +}; + +/** + * Predictive Models Extension + */ +export const PredictiveModelsExtension: ExtensionDefinition = { + key: 'ai_assistant.predictiveModels', + pluginId: 'ai_assistant', + label: 'Predictive Models', + description: 'Configuration for predictive models', + type: 'array', + appliesTo: ['object'], + required: false, +}; + +/** + * Auto-classification Extension + * + * Enables automatic classification of records. + * + * @example + * { + * name: 'email', + * fields: { ... }, + * extensions: { + * 'ai_assistant.autoClassification': true, + * 'ai_assistant.classificationField': 'category', + * 'ai_assistant.classificationModel': 'gpt-4o-mini', + * 'ai_assistant.classificationPrompt': 'Classify this email into: Support, Sales, or General' + * } + * } + */ +export const AutoClassificationExtension: ExtensionDefinition = { + key: 'ai_assistant.autoClassification', + pluginId: 'ai_assistant', + label: 'Auto Classification', + description: 'Automatically classify records using AI', + type: 'boolean', + default: false, + appliesTo: ['object'], + required: false, +}; + +/** + * Classification Field Extension + */ +export const ClassificationFieldExtension: ExtensionDefinition = { + key: 'ai_assistant.classificationField', + pluginId: 'ai_assistant', + label: 'Classification Field', + description: 'Field to store the classification result', + type: 'string', + appliesTo: ['object'], + required: false, +}; + +/** + * Classification Model Extension + */ +export const ClassificationModelExtension: ExtensionDefinition = { + key: 'ai_assistant.classificationModel', + pluginId: 'ai_assistant', + label: 'Classification Model', + description: 'The LLM model to use for classification', + type: 'string', + default: 'gpt-4o-mini', + appliesTo: ['object'], + required: false, +}; + +/** + * Classification Prompt Extension + */ +export const ClassificationPromptExtension: ExtensionDefinition = { + key: 'ai_assistant.classificationPrompt', + pluginId: 'ai_assistant', + label: 'Classification Prompt', + description: 'The prompt template for classification', + type: 'string', + appliesTo: ['object'], + required: false, +}; + +/** + * Intelligent Data Quality Extension + * + * Enables AI-powered data quality checks. + * + * @example + * { + * name: 'account', + * fields: { ... }, + * extensions: { + * 'ai_assistant.dataQualityEnabled': true, + * 'ai_assistant.dataQualityRules': [ + * { + * type: 'completeness', + * fields: ['name', 'email', 'phone'], + * threshold: 0.8 + * }, + * { + * type: 'consistency', + * checkDuplicates: true + * } + * ] + * } + * } + */ +export const DataQualityEnabledExtension: ExtensionDefinition = { + key: 'ai_assistant.dataQualityEnabled', + pluginId: 'ai_assistant', + label: 'Data Quality Enabled', + description: 'Enable AI-powered data quality checks', + type: 'boolean', + default: false, + appliesTo: ['object'], + required: false, +}; + +/** + * Data Quality Rules Extension + */ +export const DataQualityRulesExtension: ExtensionDefinition = { + key: 'ai_assistant.dataQualityRules', + pluginId: 'ai_assistant', + label: 'Data Quality Rules', + description: 'Configuration for data quality rules', + type: 'array', + appliesTo: ['object'], + required: false, +}; + +/** + * AI Object Extensions Registry + */ +export const AIObjectExtensions = { + EnableRAGExtension, + ContextFieldsExtension, + VectorIndexExtension, + ObjectEmbeddingModelExtension, + AgentEnabledExtension, + AgentNameExtension, + AgentTriggersExtension, + PredictiveEnabledExtension, + PredictiveModelsExtension, + AutoClassificationExtension, + ClassificationFieldExtension, + ClassificationModelExtension, + ClassificationPromptExtension, + DataQualityEnabledExtension, + DataQualityRulesExtension, +}; + +/** + * AI Object Extension Schema + * + * Zod schema for AI object extensions. + * This can be used for runtime validation. + */ +export const AIObjectExtensionSchema = z.object({ + 'ai_assistant.enableRAG': z.boolean(), + 'ai_assistant.contextFields': z.array(z.string()), + 'ai_assistant.vectorIndex': z.string(), + 'ai_assistant.embeddingModel': z.string(), + 'ai_assistant.agentEnabled': z.boolean(), + 'ai_assistant.agentName': z.string(), + 'ai_assistant.agentTriggers': z.array(z.string()), + 'ai_assistant.predictiveEnabled': z.boolean(), + 'ai_assistant.predictiveModels': z.array(z.any()), + 'ai_assistant.autoClassification': z.boolean(), + 'ai_assistant.classificationField': z.string(), + 'ai_assistant.classificationModel': z.string(), + 'ai_assistant.classificationPrompt': z.string(), + 'ai_assistant.dataQualityEnabled': z.boolean(), + 'ai_assistant.dataQualityRules': z.array(z.any()), +}).partial(); + +export type AIObjectExtension = z.infer; diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index ad4ca68b48..aab93a204a 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -851,3 +851,81 @@ describe('Field Factory Helpers', () => { }); }); }); + +describe('Field Extensions', () => { + it('should accept field with extensions', () => { + const fieldWithExtensions: Field = { + name: 'description', + label: 'Description', + type: 'textarea', + extensions: { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + }, + }; + + expect(() => FieldSchema.parse(fieldWithExtensions)).not.toThrow(); + const parsed = FieldSchema.parse(fieldWithExtensions); + expect(parsed.extensions).toEqual({ + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + }); + }); + + it('should accept field without extensions', () => { + const field: Field = { + name: 'name', + label: 'Name', + type: 'text', + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); + + it('should accept field with empty extensions', () => { + const field: Field = { + name: 'name', + label: 'Name', + type: 'text', + extensions: {}, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); + + it('should accept field with complex extension values', () => { + const field: Field = { + name: 'config', + label: 'Configuration', + type: 'text', + extensions: { + 'plugin.arrayValue': ['value1', 'value2'], + 'plugin.objectValue': { + nested: { + deep: true, + count: 42, + }, + }, + 'plugin.booleanValue': false, + 'plugin.numberValue': 3.14, + 'plugin.stringValue': 'test', + }, + }; + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); + + it('should work with Field factory helpers', () => { + const field = Field.text({ + name: 'summary', + label: 'Summary', + extensions: { + 'ai_assistant.vectorIndexed': true, + }, + }); + + expect(() => FieldSchema.parse(field)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f5904d6119..41d0a160f7 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ExtensionsMapSchema } from '../system/extension.zod'; /** * Field Type Enum @@ -191,6 +192,21 @@ export const FieldSchema = z.object({ /** Indexing */ index: z.boolean().default(false).describe('Create standard database index'), externalId: z.boolean().default(false).describe('Is external ID for upsert operations'), + + /** + * Extensions + * + * Custom extension properties from plugins and modules. + * Use namespaced keys (e.g., 'ai_assistant.vectorIndexed', 'crm_sync.salesforceField'). + * + * @example + * { + * 'ai_assistant.vectorIndexed': true, + * 'ai_assistant.embeddingModel': 'text-embedding-3-small', + * 'ai_assistant.chunkSize': 512 + * } + */ + extensions: ExtensionsMapSchema, }); export type Field = z.infer; diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 19185f8fc3..129b6c54f1 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -365,3 +365,85 @@ describe('ObjectSchema', () => { }); }); }); + +describe('Object Extensions', () => { + it('should accept object with extensions', () => { + const objectWithExtensions: ServiceObject = { + name: 'customer', + label: 'Customer', + fields: {}, + extensions: { + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['name', 'description', 'notes'], + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }, + }; + + expect(() => ObjectSchema.parse(objectWithExtensions)).not.toThrow(); + const parsed = ObjectSchema.parse(objectWithExtensions); + expect(parsed.extensions).toEqual({ + 'ai_assistant.enableRAG': true, + 'ai_assistant.contextFields': ['name', 'description', 'notes'], + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }); + }); + + it('should accept object without extensions', () => { + const object: ServiceObject = { + name: 'product', + label: 'Product', + fields: {}, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should accept object with empty extensions', () => { + const object: ServiceObject = { + name: 'product', + label: 'Product', + fields: {}, + extensions: {}, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should accept object with complex extension values', () => { + const object: ServiceObject = { + name: 'order', + label: 'Order', + fields: {}, + extensions: { + 'workflow_engine.autoApprovalRules': [ + { field: 'amount', operator: '<', value: 1000 }, + { field: 'status', operator: '=', value: 'pending' }, + ], + 'integration.webhookConfig': { + url: 'https://api.example.com/webhook', + headers: { + 'Authorization': 'Bearer token', + }, + events: ['onCreate', 'onUpdate', 'onDelete'], + }, + 'analytics.trackingEnabled': true, + }, + }; + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); + + it('should work with ObjectSchema.create factory', () => { + const object = ObjectSchema.create({ + name: 'task', + label: 'Task', + fields: {}, + extensions: { + 'ai_assistant.enableRAG': true, + }, + }); + + expect(() => ObjectSchema.parse(object)).not.toThrow(); + }); +}); + diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f24c973d02..abc8f47b52 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { FieldSchema } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; +import { ExtensionsMapSchema } from '../system/extension.zod'; /** * Capability Flags @@ -134,6 +135,22 @@ const ObjectSchemaBase = z.object({ * System Capabilities */ enable: ObjectCapabilities.optional().describe('Enabled system features modules'), + + /** + * Extensions + * + * Custom extension properties from plugins and modules. + * Use namespaced keys (e.g., 'ai_assistant.enableRAG', 'workflow_engine.autoApprovalRules'). + * + * @example + * { + * 'ai_assistant.enableRAG': true, + * 'ai_assistant.contextFields': ['name', 'description', 'notes'], + * 'ai_assistant.embeddingModel': 'text-embedding-3-small', + * 'workflow_engine.autoApprovalRules': [...] + * } + */ + extensions: ExtensionsMapSchema, }); /** diff --git a/packages/spec/src/system/extension.test.ts b/packages/spec/src/system/extension.test.ts new file mode 100644 index 0000000000..c65b85a127 --- /dev/null +++ b/packages/spec/src/system/extension.test.ts @@ -0,0 +1,396 @@ +import { describe, it, expect } from 'vitest'; +import { + ExtensionValueSchema, + ExtensionsMapSchema, + ExtensionDefinitionSchema, + ExtensionRegistrySchema, + Extension, + type ExtensionsMap, + type ExtensionDefinition, + type ExtensionRegistry, +} from './extension.zod'; + +describe('ExtensionValueSchema', () => { + it('should accept string values', () => { + expect(() => ExtensionValueSchema.parse('text-embedding-3-small')).not.toThrow(); + }); + + it('should accept number values', () => { + expect(() => ExtensionValueSchema.parse(512)).not.toThrow(); + expect(() => ExtensionValueSchema.parse(3.14)).not.toThrow(); + }); + + it('should accept boolean values', () => { + expect(() => ExtensionValueSchema.parse(true)).not.toThrow(); + expect(() => ExtensionValueSchema.parse(false)).not.toThrow(); + }); + + it('should accept null values', () => { + expect(() => ExtensionValueSchema.parse(null)).not.toThrow(); + }); + + it('should accept array values', () => { + expect(() => ExtensionValueSchema.parse(['onCreate', 'onUpdate'])).not.toThrow(); + expect(() => ExtensionValueSchema.parse([1, 2, 3])).not.toThrow(); + }); + + it('should accept object values', () => { + expect(() => ExtensionValueSchema.parse({ key: 'value', nested: { deep: true } })).not.toThrow(); + }); +}); + +describe('ExtensionsMapSchema', () => { + it('should accept valid extensions map', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + 'crm_sync.salesforceId': 'Contact.Email__c', + 'workflow_engine.triggers': ['onCreate', 'onUpdate'], + }; + + expect(() => ExtensionsMapSchema.parse(extensions)).not.toThrow(); + }); + + it('should accept empty extensions map', () => { + expect(() => ExtensionsMapSchema.parse({})).not.toThrow(); + }); + + it('should accept undefined', () => { + expect(() => ExtensionsMapSchema.parse(undefined)).not.toThrow(); + }); + + it('should accept nested object values', () => { + const extensions = { + 'plugin.complexConfig': { + enabled: true, + settings: { + apiKey: 'secret', + timeout: 5000, + }, + }, + }; + + expect(() => ExtensionsMapSchema.parse(extensions)).not.toThrow(); + }); +}); + +describe('ExtensionDefinitionSchema', () => { + it('should accept valid extension definition', () => { + const definition: ExtensionDefinition = { + key: 'ai_assistant.vectorIndexed', + pluginId: 'ai_assistant', + label: 'Vector Indexed', + description: 'Whether this field should be indexed for vector search', + type: 'boolean', + default: false, + appliesTo: ['field'], + fieldTypes: ['text', 'textarea', 'markdown'], + required: false, + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).not.toThrow(); + }); + + it('should accept minimal extension definition', () => { + const definition = { + key: 'plugin_name.propertyName', + pluginId: 'plugin_name', + label: 'Property Label', + type: 'string', + appliesTo: ['object'], + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).not.toThrow(); + }); + + it('should reject invalid key format (no namespace)', () => { + const definition = { + key: 'invalidkey', + pluginId: 'plugin', + label: 'Label', + type: 'string', + appliesTo: ['field'], + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).toThrow(); + }); + + it('should reject invalid key format (uppercase in namespace)', () => { + const definition = { + key: 'PluginName.property', + pluginId: 'plugin', + label: 'Label', + type: 'string', + appliesTo: ['field'], + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).toThrow(); + }); + + it('should accept valid appliesTo values', () => { + const validAppliesTo = [ + ['object'], + ['field'], + ['view'], + ['app'], + ['dashboard'], + ['report'], + ['action'], + ['workflow'], + ['object', 'field'], + ['view', 'dashboard', 'report'], + ]; + + validAppliesTo.forEach(appliesTo => { + const definition = { + key: 'plugin.property', + pluginId: 'plugin', + label: 'Label', + type: 'string', + appliesTo, + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).not.toThrow(); + }); + }); + + it('should accept all type values', () => { + const types = ['string', 'number', 'boolean', 'object', 'array', 'any']; + + types.forEach(type => { + const definition = { + key: 'plugin.property', + pluginId: 'plugin', + label: 'Label', + type, + appliesTo: ['field'], + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).not.toThrow(); + }); + }); + + it('should include schema field for advanced validation', () => { + const definition = { + key: 'plugin.property', + pluginId: 'plugin', + label: 'Label', + type: 'object', + appliesTo: ['field'], + schema: { + type: 'object', + properties: { + apiKey: { type: 'string' }, + timeout: { type: 'number' }, + }, + required: ['apiKey'], + }, + }; + + expect(() => ExtensionDefinitionSchema.parse(definition)).not.toThrow(); + }); +}); + +describe('ExtensionRegistrySchema', () => { + it('should accept valid extension registry', () => { + const registry: ExtensionRegistry = { + extensions: { + 'ai_assistant.vectorIndexed': { + key: 'ai_assistant.vectorIndexed', + pluginId: 'ai_assistant', + label: 'Vector Indexed', + type: 'boolean', + appliesTo: ['field'], + }, + 'ai_assistant.enableRAG': { + key: 'ai_assistant.enableRAG', + pluginId: 'ai_assistant', + label: 'Enable RAG', + type: 'boolean', + appliesTo: ['object'], + }, + }, + }; + + expect(() => ExtensionRegistrySchema.parse(registry)).not.toThrow(); + }); + + it('should accept empty registry', () => { + const registry = { + extensions: {}, + }; + + expect(() => ExtensionRegistrySchema.parse(registry)).not.toThrow(); + }); +}); + +describe('Extension helper functions', () => { + describe('Extension.key', () => { + it('should create namespaced key', () => { + expect(Extension.key('ai_assistant', 'vectorIndexed')).toBe('ai_assistant.vectorIndexed'); + expect(Extension.key('crm_sync', 'salesforceId')).toBe('crm_sync.salesforceId'); + }); + }); + + describe('Extension.get', () => { + it('should get extension value', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + 'ai_assistant.chunkSize': 512, + }; + + expect(Extension.get(extensions, 'ai_assistant.vectorIndexed')).toBe(true); + expect(Extension.get(extensions, 'ai_assistant.embeddingModel')).toBe('text-embedding-3-small'); + expect(Extension.get(extensions, 'ai_assistant.chunkSize')).toBe(512); + }); + + it('should return default value if extension not found', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + }; + + expect(Extension.get(extensions, 'nonexistent.key', false)).toBe(false); + expect(Extension.get(extensions, 'nonexistent.key', 'default')).toBe('default'); + }); + + it('should return default value if extensions is undefined', () => { + expect(Extension.get(undefined, 'ai_assistant.vectorIndexed', false)).toBe(false); + }); + + it('should return undefined if no default and key not found', () => { + const extensions: ExtensionsMap = {}; + expect(Extension.get(extensions, 'nonexistent.key')).toBeUndefined(); + }); + }); + + describe('Extension.set', () => { + it('should set extension value', () => { + const extensions: ExtensionsMap = { + 'existing.key': 'value', + }; + + const updated = Extension.set(extensions, 'ai_assistant.vectorIndexed', true); + + expect(updated).toEqual({ + 'existing.key': 'value', + 'ai_assistant.vectorIndexed': true, + }); + }); + + it('should create extensions map if undefined', () => { + const updated = Extension.set(undefined, 'ai_assistant.vectorIndexed', true); + + expect(updated).toEqual({ + 'ai_assistant.vectorIndexed': true, + }); + }); + + it('should overwrite existing value', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': false, + }; + + const updated = Extension.set(extensions, 'ai_assistant.vectorIndexed', true); + + expect(updated['ai_assistant.vectorIndexed']).toBe(true); + }); + + it('should not mutate original extensions', () => { + const extensions: ExtensionsMap = { + 'existing.key': 'value', + }; + + const updated = Extension.set(extensions, 'new.key', 'new value'); + + expect(extensions).toEqual({ 'existing.key': 'value' }); + expect(updated).toEqual({ + 'existing.key': 'value', + 'new.key': 'new value', + }); + }); + }); + + describe('Extension.has', () => { + it('should return true if extension exists', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + }; + + expect(Extension.has(extensions, 'ai_assistant.vectorIndexed')).toBe(true); + }); + + it('should return false if extension does not exist', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + }; + + expect(Extension.has(extensions, 'nonexistent.key')).toBe(false); + }); + + it('should return false if extensions is undefined', () => { + expect(Extension.has(undefined, 'ai_assistant.vectorIndexed')).toBe(false); + }); + + it('should return true even if value is falsy', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': false, + 'ai_assistant.nullValue': null, + 'ai_assistant.zeroValue': 0, + }; + + expect(Extension.has(extensions, 'ai_assistant.vectorIndexed')).toBe(true); + expect(Extension.has(extensions, 'ai_assistant.nullValue')).toBe(true); + expect(Extension.has(extensions, 'ai_assistant.zeroValue')).toBe(true); + }); + }); + + describe('Extension.remove', () => { + it('should remove extension', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }; + + const updated = Extension.remove(extensions, 'ai_assistant.vectorIndexed'); + + expect(updated).toEqual({ + 'ai_assistant.embeddingModel': 'text-embedding-3-small', + }); + }); + + it('should return undefined if last extension removed', () => { + const extensions: ExtensionsMap = { + 'ai_assistant.vectorIndexed': true, + }; + + const updated = Extension.remove(extensions, 'ai_assistant.vectorIndexed'); + + expect(updated).toBeUndefined(); + }); + + it('should return undefined if extensions is undefined', () => { + const updated = Extension.remove(undefined, 'ai_assistant.vectorIndexed'); + + expect(updated).toBeUndefined(); + }); + + it('should not mutate original extensions', () => { + const extensions: ExtensionsMap = { + 'key1': 'value1', + 'key2': 'value2', + }; + + const updated = Extension.remove(extensions, 'key1'); + + expect(extensions).toEqual({ + 'key1': 'value1', + 'key2': 'value2', + }); + expect(updated).toEqual({ + 'key2': 'value2', + }); + }); + }); +}); diff --git a/packages/spec/src/system/extension.zod.ts b/packages/spec/src/system/extension.zod.ts new file mode 100644 index 0000000000..6e7cba985e --- /dev/null +++ b/packages/spec/src/system/extension.zod.ts @@ -0,0 +1,273 @@ +import { z } from 'zod'; + +/** + * Metadata Extension Schema + * + * Defines the protocol for extending standard metadata definitions. + * This allows plugins and extensions to add custom properties to Objects, Fields, and other metadata. + * + * Best Practice: Extension Namespacing + * - Use dot notation for namespace: `plugin_id.property_name` + * - Example: `ai_assistant.vectorIndexed`, `crm_sync.salesforceField` + * + * @example + * // Field with AI extensions + * const field = { + * name: 'description', + * type: 'textarea', + * extensions: { + * 'ai_assistant.vectorIndexed': true, + * 'ai_assistant.embeddingModel': 'text-embedding-3-small', + * 'ai_assistant.chunkSize': 512 + * } + * }; + * + * @example + * // Object with AI extensions + * const object = { + * name: 'customer', + * fields: { ... }, + * extensions: { + * 'ai_assistant.enableRAG': true, + * 'ai_assistant.contextFields': ['name', 'description', 'notes'], + * 'workflow_engine.autoApprovalRules': [...] + * } + * }; + */ + +/** + * Extension Value Schema + * + * Represents a single extension property. + * Can be any valid JSON value: primitive, object, or array. + */ +export const ExtensionValueSchema: z.ZodType = z.lazy(() => z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(ExtensionValueSchema), + z.record(ExtensionValueSchema), +])); + +export type ExtensionValue = z.infer; + +/** + * Extensions Map Schema + * + * A record of extension properties keyed by namespaced identifiers. + * + * Convention: + * - Keys should use dot notation: `namespace.property` + * - Namespace should typically be the plugin/module ID + * - Property should be camelCase + * + * @example + * { + * 'ai_assistant.vectorIndexed': true, + * 'ai_assistant.embeddingModel': 'text-embedding-3-small', + * 'crm_sync.salesforceId': 'Contact.Email__c', + * 'workflow_engine.triggers': ['onCreate', 'onUpdate'] + * } + */ +export const ExtensionsMapSchema = z.record( + z.string().describe('Namespaced extension key (e.g., "plugin_id.property_name")'), + ExtensionValueSchema.describe('Extension value (string, number, boolean, object, or array)') +).optional().describe('Custom extension properties from plugins and modules'); + +export type ExtensionsMap = z.infer; + +/** + * Extension Definition Schema + * + * Defines metadata about an extension field. + * Plugins can register these definitions to document their extensions. + * + * @example + * const aiVectorExtension: ExtensionDefinition = { + * key: 'ai_assistant.vectorIndexed', + * pluginId: 'ai_assistant', + * label: 'Vector Indexed', + * description: 'Whether this field should be indexed for vector search', + * type: 'boolean', + * default: false, + * appliesTo: ['field'], + * fieldTypes: ['text', 'textarea', 'markdown'] + * }; + */ +export const ExtensionDefinitionSchema = z.object({ + /** + * Full namespaced key for this extension. + * Convention: `plugin_id.property_name` + */ + key: z.string() + .regex(/^[a-z_][a-z0-9_]*\.[a-zA-Z][a-zA-Z0-9_]*$/) + .describe('Fully qualified extension key (e.g., "ai_assistant.vectorIndexed")'), + + /** + * Plugin or module that provides this extension + */ + pluginId: z.string().describe('ID of the plugin providing this extension'), + + /** + * Human-readable label + */ + label: z.string().describe('Display label for the extension property'), + + /** + * Description of what this extension does + */ + description: z.string().optional().describe('Detailed description of the extension'), + + /** + * Expected data type for the extension value + */ + type: z.enum([ + 'string', 'number', 'boolean', 'object', 'array', 'any' + ]).describe('Expected data type for this extension'), + + /** + * Default value if not specified + */ + default: z.any().optional().describe('Default value for this extension'), + + /** + * Which metadata types this extension can be applied to + */ + appliesTo: z.array(z.enum([ + 'object', 'field', 'view', 'app', 'dashboard', 'report', 'action', 'workflow' + ])).describe('Metadata types this extension can be applied to'), + + /** + * For field extensions: which field types support this extension + */ + fieldTypes: z.array(z.string()).optional().describe('Applicable field types (for field extensions)'), + + /** + * Whether this extension is required + */ + required: z.boolean().default(false).describe('Whether this extension is required'), + + /** + * JSON Schema for validating the extension value + */ + schema: z.any().optional().describe('JSON Schema for advanced validation'), +}); + +export type ExtensionDefinition = z.infer; + +/** + * Extension Registry Schema + * + * A registry of all available extensions in the system. + * Plugins register their extension definitions here. + * + * @example + * const registry: ExtensionRegistry = { + * extensions: { + * 'ai_assistant.vectorIndexed': { + * key: 'ai_assistant.vectorIndexed', + * pluginId: 'ai_assistant', + * label: 'Vector Indexed', + * type: 'boolean', + * appliesTo: ['field'] + * }, + * 'ai_assistant.enableRAG': { + * key: 'ai_assistant.enableRAG', + * pluginId: 'ai_assistant', + * label: 'Enable RAG', + * type: 'boolean', + * appliesTo: ['object'] + * } + * } + * }; + */ +export const ExtensionRegistrySchema = z.object({ + /** + * Map of extension definitions keyed by their full key + */ + extensions: z.record( + z.string(), + ExtensionDefinitionSchema + ).describe('Registry of available extension definitions'), +}); + +export type ExtensionRegistry = z.infer; + +/** + * Extension Helper Functions + */ +export const Extension = { + /** + * Create a namespaced extension key + * + * @param pluginId - Plugin identifier (snake_case) + * @param property - Property name (camelCase) + * @returns Namespaced key + * + * @example + * Extension.key('ai_assistant', 'vectorIndexed') // 'ai_assistant.vectorIndexed' + */ + key: (pluginId: string, property: string): string => `${pluginId}.${property}`, + + /** + * Get extension value with type safety + * + * @param extensions - Extensions map + * @param key - Extension key + * @param defaultValue - Default value if not found + * @returns Extension value or default + * + * @example + * Extension.get(field.extensions, 'ai_assistant.vectorIndexed', false) + */ + get: (extensions: ExtensionsMap | undefined, key: string, defaultValue?: T): T | undefined => { + if (!extensions) return defaultValue; + return (extensions[key] as T) ?? defaultValue; + }, + + /** + * Set extension value + * + * @param extensions - Extensions map (or undefined) + * @param key - Extension key + * @param value - Extension value + * @returns Updated extensions map + * + * @example + * field.extensions = Extension.set(field.extensions, 'ai_assistant.vectorIndexed', true) + */ + set: (extensions: ExtensionsMap | undefined, key: string, value: any): ExtensionsMap => { + return { ...extensions, [key]: value }; + }, + + /** + * Check if extension exists + * + * @param extensions - Extensions map + * @param key - Extension key + * @returns Whether the extension exists + * + * @example + * Extension.has(field.extensions, 'ai_assistant.vectorIndexed') + */ + has: (extensions: ExtensionsMap | undefined, key: string): boolean => { + return extensions ? key in extensions : false; + }, + + /** + * Remove extension + * + * @param extensions - Extensions map + * @param key - Extension key + * @returns Updated extensions map + * + * @example + * field.extensions = Extension.remove(field.extensions, 'ai_assistant.vectorIndexed') + */ + remove: (extensions: ExtensionsMap | undefined, key: string): ExtensionsMap | undefined => { + if (!extensions) return undefined; + const { [key]: _, ...rest } = extensions; + return Object.keys(rest).length > 0 ? rest : undefined; + }, +}; diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 17d89b8b2d..7cf9b28429 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -5,11 +5,13 @@ * - Manifest (Config), Datasource * - Webhook (Integration), Audit (Compliance) * - Plugin Architecture + * - Extension Mechanism */ export * from './audit.zod'; export * from './translation.zod'; export * from './events.zod'; +export * from './extension.zod'; export * from './job.zod'; export * from './types'; // Note: Auth, Identity, Policy, Role, Organization moved to @objectstack/spec/auth