diff --git a/content/docs/references/data/Trigger.mdx b/content/docs/references/data/Trigger.mdx new file mode 100644 index 000000000..9042e6d64 --- /dev/null +++ b/content/docs/references/data/Trigger.mdx @@ -0,0 +1,16 @@ +--- +title: Trigger +description: Trigger Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Trigger name (snake_case) | +| **object** | `string` | ✅ | Target object name | +| **timing** | `Enum<'before' \| 'after'>` | ✅ | Execution timing | +| **action** | `Enum<'insert' \| 'update' \| 'delete'> \| Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Database operation(s) to trigger on | +| **description** | `string` | optional | Trigger description | +| **active** | `boolean` | optional | Is trigger active | +| **order** | `number` | optional | Execution order | diff --git a/content/docs/references/data/TriggerAction.mdx b/content/docs/references/data/TriggerAction.mdx new file mode 100644 index 000000000..3e3dc35b3 --- /dev/null +++ b/content/docs/references/data/TriggerAction.mdx @@ -0,0 +1,10 @@ +--- +title: TriggerAction +description: TriggerAction Schema Reference +--- + +## Allowed Values + +* `insert` +* `update` +* `delete` \ No newline at end of file diff --git a/content/docs/references/data/TriggerContext.mdx b/content/docs/references/data/TriggerContext.mdx new file mode 100644 index 000000000..8a149d926 --- /dev/null +++ b/content/docs/references/data/TriggerContext.mdx @@ -0,0 +1,17 @@ +--- +title: TriggerContext +description: TriggerContext Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **action** | `Enum<'insert' \| 'update' \| 'delete'>` | ✅ | Database operation type | +| **timing** | `Enum<'before' \| 'after'>` | ✅ | Trigger execution timing | +| **doc** | `Record` | ✅ | Current document/record | +| **previousDoc** | `Record` | optional | Previous document state | +| **userId** | `string` | ✅ | Current user ID | +| **user** | `Record` | ✅ | Current user record | +| **ql** | `any` | optional | ObjectQL data access API | +| **logger** | `any` | optional | Logging interface | diff --git a/content/docs/references/data/TriggerTiming.mdx b/content/docs/references/data/TriggerTiming.mdx new file mode 100644 index 000000000..694af94c9 --- /dev/null +++ b/content/docs/references/data/TriggerTiming.mdx @@ -0,0 +1,9 @@ +--- +title: TriggerTiming +description: TriggerTiming Schema Reference +--- + +## Allowed Values + +* `before` +* `after` \ No newline at end of file diff --git a/content/docs/references/system/DriverCapabilities.mdx b/content/docs/references/system/DriverCapabilities.mdx new file mode 100644 index 000000000..8d3bc4bfc --- /dev/null +++ b/content/docs/references/system/DriverCapabilities.mdx @@ -0,0 +1,14 @@ +--- +title: DriverCapabilities +description: DriverCapabilities Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **transactions** | `boolean` | ✅ | Supports transactions | +| **joins** | `boolean` | ✅ | Supports SQL joins | +| **fullTextSearch** | `boolean` | ✅ | Supports full-text search | +| **jsonFields** | `boolean` | ✅ | Supports JSON field types | +| **arrayFields** | `boolean` | ✅ | Supports array field types | diff --git a/content/docs/references/system/DriverInterface.mdx b/content/docs/references/system/DriverInterface.mdx new file mode 100644 index 000000000..28e3bfeb4 --- /dev/null +++ b/content/docs/references/system/DriverInterface.mdx @@ -0,0 +1,12 @@ +--- +title: DriverInterface +description: DriverInterface Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Driver name | +| **version** | `string` | ✅ | Driver version | +| **supports** | `object` | ✅ | Driver capabilities | diff --git a/content/docs/references/system/Plugin.mdx b/content/docs/references/system/Plugin.mdx new file mode 100644 index 000000000..00d48a3fe --- /dev/null +++ b/content/docs/references/system/Plugin.mdx @@ -0,0 +1,11 @@ +--- +title: Plugin +description: Plugin Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Plugin identifier | +| **version** | `string` | optional | Plugin version | diff --git a/content/docs/references/system/PluginContext.mdx b/content/docs/references/system/PluginContext.mdx new file mode 100644 index 000000000..b3ddfa944 --- /dev/null +++ b/content/docs/references/system/PluginContext.mdx @@ -0,0 +1,14 @@ +--- +title: PluginContext +description: PluginContext Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **ql** | `any` | optional | ObjectQL data access API | +| **os** | `any` | optional | ObjectOS system API | +| **logger** | `any` | optional | Logging interface | +| **metadata** | `any` | optional | Metadata registry | +| **events** | `any` | optional | Event bus | diff --git a/content/docs/references/system/PluginLifecycle.mdx b/content/docs/references/system/PluginLifecycle.mdx new file mode 100644 index 000000000..3e4cbb022 --- /dev/null +++ b/content/docs/references/system/PluginLifecycle.mdx @@ -0,0 +1,9 @@ +--- +title: PluginLifecycle +description: PluginLifecycle Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | diff --git a/content/docs/references/ui/FieldWidgetProps.mdx b/content/docs/references/ui/FieldWidgetProps.mdx new file mode 100644 index 000000000..3ff8a96d6 --- /dev/null +++ b/content/docs/references/ui/FieldWidgetProps.mdx @@ -0,0 +1,16 @@ +--- +title: FieldWidgetProps +description: FieldWidgetProps Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `any` | optional | Current field value | +| **readonly** | `boolean` | optional | Read-only mode flag | +| **required** | `boolean` | optional | Required field flag | +| **error** | `string` | optional | Validation error message | +| **field** | `object` | ✅ | Field schema definition | +| **record** | `Record` | optional | Complete record data | +| **options** | `Record` | optional | Custom widget options | diff --git a/packages/spec/json-schema/DriverCapabilities.json b/packages/spec/json-schema/DriverCapabilities.json new file mode 100644 index 000000000..4da7f6732 --- /dev/null +++ b/packages/spec/json-schema/DriverCapabilities.json @@ -0,0 +1,39 @@ +{ + "$ref": "#/definitions/DriverCapabilities", + "definitions": { + "DriverCapabilities": { + "type": "object", + "properties": { + "transactions": { + "type": "boolean", + "description": "Supports transactions" + }, + "joins": { + "type": "boolean", + "description": "Supports SQL joins" + }, + "fullTextSearch": { + "type": "boolean", + "description": "Supports full-text search" + }, + "jsonFields": { + "type": "boolean", + "description": "Supports JSON field types" + }, + "arrayFields": { + "type": "boolean", + "description": "Supports array field types" + } + }, + "required": [ + "transactions", + "joins", + "fullTextSearch", + "jsonFields", + "arrayFields" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/DriverInterface.json b/packages/spec/json-schema/DriverInterface.json new file mode 100644 index 000000000..496079bee --- /dev/null +++ b/packages/spec/json-schema/DriverInterface.json @@ -0,0 +1,59 @@ +{ + "$ref": "#/definitions/DriverInterface", + "definitions": { + "DriverInterface": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Driver name" + }, + "version": { + "type": "string", + "description": "Driver version" + }, + "supports": { + "type": "object", + "properties": { + "transactions": { + "type": "boolean", + "description": "Supports transactions" + }, + "joins": { + "type": "boolean", + "description": "Supports SQL joins" + }, + "fullTextSearch": { + "type": "boolean", + "description": "Supports full-text search" + }, + "jsonFields": { + "type": "boolean", + "description": "Supports JSON field types" + }, + "arrayFields": { + "type": "boolean", + "description": "Supports array field types" + } + }, + "required": [ + "transactions", + "joins", + "fullTextSearch", + "jsonFields", + "arrayFields" + ], + "additionalProperties": false, + "description": "Driver capabilities" + } + }, + "required": [ + "name", + "version", + "supports" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/FieldWidgetProps.json b/packages/spec/json-schema/FieldWidgetProps.json new file mode 100644 index 000000000..ab78a1016 --- /dev/null +++ b/packages/spec/json-schema/FieldWidgetProps.json @@ -0,0 +1,262 @@ +{ + "$ref": "#/definitions/FieldWidgetProps", + "definitions": { + "FieldWidgetProps": { + "type": "object", + "properties": { + "value": { + "description": "Current field value" + }, + "readonly": { + "type": "boolean", + "default": false, + "description": "Read-only mode flag" + }, + "required": { + "type": "boolean", + "default": false, + "description": "Required field flag" + }, + "error": { + "type": "string", + "description": "Validation error message" + }, + "field": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "description": "Machine name (snake_case)" + }, + "label": { + "type": "string", + "description": "Human readable label" + }, + "type": { + "type": "string", + "enum": [ + "text", + "textarea", + "email", + "url", + "phone", + "password", + "markdown", + "html", + "number", + "currency", + "percent", + "date", + "datetime", + "time", + "boolean", + "select", + "lookup", + "master_detail", + "image", + "file", + "avatar", + "formula", + "summary", + "autonumber" + ], + "description": "Field Data Type" + }, + "description": { + "type": "string", + "description": "Tooltip/Help text" + }, + "format": { + "type": "string", + "description": "Format string (e.g. email, phone)" + }, + "required": { + "type": "boolean", + "default": false, + "description": "Is required" + }, + "searchable": { + "type": "boolean", + "default": false, + "description": "Is searchable" + }, + "multiple": { + "type": "boolean", + "default": false, + "description": "Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Is unique constraint" + }, + "defaultValue": { + "description": "Default value" + }, + "maxLength": { + "type": "number", + "description": "Max character length" + }, + "minLength": { + "type": "number", + "description": "Min character length" + }, + "precision": { + "type": "number", + "description": "Total digits" + }, + "scale": { + "type": "number", + "description": "Decimal places" + }, + "min": { + "type": "number", + "description": "Minimum value" + }, + "max": { + "type": "number", + "description": "Maximum value" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display label" + }, + "value": { + "type": "string", + "description": "Stored value" + }, + "color": { + "type": "string", + "description": "Color code for badges/charts" + }, + "default": { + "type": "boolean", + "description": "Is default option" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "description": "Static options for select/multiselect" + }, + "reference": { + "type": "string", + "description": "Target Object Name" + }, + "referenceFilters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filters applied to lookup dialogs (e.g. \"active = true\")" + }, + "writeRequiresMasterRead": { + "type": "boolean", + "description": "If true, user needs read access to master record to edit this field" + }, + "deleteBehavior": { + "type": "string", + "enum": [ + "set_null", + "cascade", + "restrict" + ], + "default": "set_null", + "description": "What happens if referenced record is deleted" + }, + "expression": { + "type": "string", + "description": "Formula expression" + }, + "formula": { + "type": "string", + "description": "Deprecated: Use expression" + }, + "summaryOperations": { + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "field": { + "type": "string" + }, + "function": { + "type": "string", + "enum": [ + "count", + "sum", + "min", + "max", + "avg" + ] + } + }, + "required": [ + "object", + "field", + "function" + ], + "additionalProperties": false, + "description": "Roll-up summary definition" + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Hidden from default UI" + }, + "readonly": { + "type": "boolean", + "default": false, + "description": "Read-only in UI" + }, + "encryption": { + "type": "boolean", + "default": false, + "description": "Encrypt at rest" + }, + "index": { + "type": "boolean", + "default": false, + "description": "Create standard database index" + }, + "externalId": { + "type": "boolean", + "default": false, + "description": "Is external ID for upsert operations" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "description": "Field schema definition" + }, + "record": { + "type": "object", + "additionalProperties": {}, + "description": "Complete record data" + }, + "options": { + "type": "object", + "additionalProperties": {}, + "description": "Custom widget options" + } + }, + "required": [ + "field" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Plugin.json b/packages/spec/json-schema/Plugin.json new file mode 100644 index 000000000..52a64a9e2 --- /dev/null +++ b/packages/spec/json-schema/Plugin.json @@ -0,0 +1,20 @@ +{ + "$ref": "#/definitions/Plugin", + "definitions": { + "Plugin": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Plugin identifier" + }, + "version": { + "type": "string", + "description": "Plugin version" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/PluginContext.json b/packages/spec/json-schema/PluginContext.json new file mode 100644 index 000000000..ace54d686 --- /dev/null +++ b/packages/spec/json-schema/PluginContext.json @@ -0,0 +1,27 @@ +{ + "$ref": "#/definitions/PluginContext", + "definitions": { + "PluginContext": { + "type": "object", + "properties": { + "ql": { + "description": "ObjectQL data access API" + }, + "os": { + "description": "ObjectOS system API" + }, + "logger": { + "description": "Logging interface" + }, + "metadata": { + "description": "Metadata registry" + }, + "events": { + "description": "Event bus" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/PluginLifecycle.json b/packages/spec/json-schema/PluginLifecycle.json new file mode 100644 index 000000000..4f50e0abf --- /dev/null +++ b/packages/spec/json-schema/PluginLifecycle.json @@ -0,0 +1,11 @@ +{ + "$ref": "#/definitions/PluginLifecycle", + "definitions": { + "PluginLifecycle": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Trigger.json b/packages/spec/json-schema/Trigger.json new file mode 100644 index 000000000..ab20efdb3 --- /dev/null +++ b/packages/spec/json-schema/Trigger.json @@ -0,0 +1,73 @@ +{ + "$ref": "#/definitions/Trigger", + "definitions": { + "Trigger": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "description": "Trigger name (snake_case)" + }, + "object": { + "type": "string", + "description": "Target object name" + }, + "timing": { + "type": "string", + "enum": [ + "before", + "after" + ], + "description": "Execution timing" + }, + "action": { + "anyOf": [ + { + "type": "string", + "enum": [ + "insert", + "update", + "delete" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "insert", + "update", + "delete" + ] + } + } + ], + "description": "Database operation(s) to trigger on" + }, + "description": { + "type": "string", + "description": "Trigger description" + }, + "active": { + "type": "boolean", + "default": true, + "description": "Is trigger active" + }, + "order": { + "type": "number", + "default": 0, + "description": "Execution order" + } + }, + "required": [ + "name", + "object", + "timing", + "action" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/TriggerAction.json b/packages/spec/json-schema/TriggerAction.json new file mode 100644 index 000000000..fc750a983 --- /dev/null +++ b/packages/spec/json-schema/TriggerAction.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/TriggerAction", + "definitions": { + "TriggerAction": { + "type": "string", + "enum": [ + "insert", + "update", + "delete" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/TriggerContext.json b/packages/spec/json-schema/TriggerContext.json new file mode 100644 index 000000000..57a6ffd55 --- /dev/null +++ b/packages/spec/json-schema/TriggerContext.json @@ -0,0 +1,61 @@ +{ + "$ref": "#/definitions/TriggerContext", + "definitions": { + "TriggerContext": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "insert", + "update", + "delete" + ], + "description": "Database operation type" + }, + "timing": { + "type": "string", + "enum": [ + "before", + "after" + ], + "description": "Trigger execution timing" + }, + "doc": { + "type": "object", + "additionalProperties": {}, + "description": "Current document/record" + }, + "previousDoc": { + "type": "object", + "additionalProperties": {}, + "description": "Previous document state" + }, + "userId": { + "type": "string", + "description": "Current user ID" + }, + "user": { + "type": "object", + "additionalProperties": {}, + "description": "Current user record" + }, + "ql": { + "description": "ObjectQL data access API" + }, + "logger": { + "description": "Logging interface" + } + }, + "required": [ + "action", + "timing", + "doc", + "userId", + "user" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/TriggerTiming.json b/packages/spec/json-schema/TriggerTiming.json new file mode 100644 index 000000000..223c2c402 --- /dev/null +++ b/packages/spec/json-schema/TriggerTiming.json @@ -0,0 +1,13 @@ +{ + "$ref": "#/definitions/TriggerTiming", + "definitions": { + "TriggerTiming": { + "type": "string", + "enum": [ + "before", + "after" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/data/trigger.test.ts b/packages/spec/src/data/trigger.test.ts new file mode 100644 index 000000000..02f7b87cb --- /dev/null +++ b/packages/spec/src/data/trigger.test.ts @@ -0,0 +1,495 @@ +import { describe, it, expect } from 'vitest'; +import { + TriggerAction, + TriggerTiming, + TriggerContextSchema, + TriggerSchema, + type TriggerContext, + type Trigger, +} from './trigger.zod'; + +describe('TriggerAction', () => { + it('should accept valid actions', () => { + const validActions = ['insert', 'update', 'delete']; + + validActions.forEach(action => { + expect(() => TriggerAction.parse(action)).not.toThrow(); + }); + }); + + it('should reject invalid actions', () => { + expect(() => TriggerAction.parse('create')).toThrow(); + expect(() => TriggerAction.parse('modify')).toThrow(); + expect(() => TriggerAction.parse('remove')).toThrow(); + }); +}); + +describe('TriggerTiming', () => { + it('should accept valid timings', () => { + const validTimings = ['before', 'after']; + + validTimings.forEach(timing => { + expect(() => TriggerTiming.parse(timing)).not.toThrow(); + }); + }); + + it('should reject invalid timings', () => { + expect(() => TriggerTiming.parse('during')).toThrow(); + expect(() => TriggerTiming.parse('pre')).toThrow(); + expect(() => TriggerTiming.parse('post')).toThrow(); + }); +}); + +describe('TriggerContextSchema', () => { + describe('Valid Contexts', () => { + it('should accept minimal valid context', () => { + const context: TriggerContext = { + action: 'insert', + timing: 'before', + doc: { name: 'Test' }, + userId: 'user123', + user: { id: 'user123', name: 'John Doe' }, + ql: {}, + logger: {}, + addError: (message: string, field?: string) => {}, + getOldValue: (field: string) => undefined, + }; + + expect(() => TriggerContextSchema.parse(context)).not.toThrow(); + }); + + it('should accept context with previousDoc', () => { + const context: TriggerContext = { + action: 'update', + timing: 'before', + doc: { id: '1', name: 'Updated Name', status: 'active' }, + previousDoc: { id: '1', name: 'Old Name', status: 'inactive' }, + userId: 'user123', + user: { id: 'user123', name: 'John Doe' }, + ql: {}, + logger: {}, + addError: () => {}, + getOldValue: (field: string) => undefined, + }; + + expect(() => TriggerContextSchema.parse(context)).not.toThrow(); + }); + + it('should accept context with complete implementations', () => { + const context = { + action: 'update' as const, + timing: 'after' as const, + doc: { id: '1', name: 'Test', amount: 100 }, + previousDoc: { id: '1', name: 'Test', amount: 50 }, + userId: 'user123', + user: { + id: 'user123', + name: 'John Doe', + email: 'john@example.com', + roles: ['admin'], + }, + ql: { + object: (name: string) => ({ + find: async () => [], + create: async (data: any) => data, + }), + }, + logger: { + info: (message: string, meta?: any) => console.log(message, meta), + error: (message: string, error?: any) => console.error(message, error), + }, + addError: (message: string, field?: string) => { + console.error(`Error on ${field}: ${message}`); + }, + getOldValue: (field: string) => { + const prev: any = { id: '1', name: 'Test', amount: 50 }; + return prev[field]; + }, + }; + + expect(() => TriggerContextSchema.parse(context)).not.toThrow(); + }); + }); + + describe('Required Fields', () => { + it('should require action', () => { + const context = { + timing: 'before', + doc: {}, + userId: 'user123', + user: {}, + ql: {}, + logger: {}, + addError: () => {}, + getOldValue: () => undefined, + }; + + const result = TriggerContextSchema.safeParse(context); + expect(result.success).toBe(false); + }); + + it('should require timing', () => { + const context = { + action: 'insert', + doc: {}, + userId: 'user123', + user: {}, + ql: {}, + logger: {}, + addError: () => {}, + getOldValue: () => undefined, + }; + + const result = TriggerContextSchema.safeParse(context); + expect(result.success).toBe(false); + }); + + it('should require doc', () => { + const context = { + action: 'insert', + timing: 'before', + userId: 'user123', + user: {}, + ql: {}, + logger: {}, + addError: () => {}, + getOldValue: () => undefined, + }; + + const result = TriggerContextSchema.safeParse(context); + expect(result.success).toBe(false); + }); + }); +}); + +describe('TriggerSchema', () => { + describe('Valid Triggers', () => { + it('should accept minimal trigger definition', () => { + const trigger: Trigger = { + name: 'validate_account', + object: 'account', + timing: 'before', + action: 'insert', + execute: async (context: TriggerContext) => { + // Validation logic + }, + }; + + expect(() => TriggerSchema.parse(trigger)).not.toThrow(); + }); + + it('should accept trigger with all fields', () => { + const trigger: Trigger = { + name: 'update_related_records', + object: 'opportunity', + timing: 'after', + action: 'update', + execute: async (context: TriggerContext) => { + // Business logic + }, + description: 'Updates related records when opportunity is modified', + active: true, + order: 10, + }; + + expect(() => TriggerSchema.parse(trigger)).not.toThrow(); + }); + + it('should accept trigger with multiple actions', () => { + const trigger: Trigger = { + name: 'audit_changes', + object: 'contact', + timing: 'after', + action: ['insert', 'update', 'delete'], + execute: async (context: TriggerContext) => { + await context.ql.object('audit_log').create({ + action: context.action, + record_id: context.doc.id, + user_id: context.userId, + }); + }, + }; + + expect(() => TriggerSchema.parse(trigger)).not.toThrow(); + }); + + it('should enforce snake_case for trigger name', () => { + const validNames = ['validate_account', 'update_status', 'before_insert']; + + validNames.forEach(name => { + const trigger = { + name, + object: 'test', + timing: 'before' as const, + action: 'insert' as const, + execute: async () => {}, + }; + expect(() => TriggerSchema.parse(trigger)).not.toThrow(); + }); + + const invalidNames = ['validateAccount', 'Update-Status', '123invalid']; + + invalidNames.forEach(name => { + const trigger = { + name, + object: 'test', + timing: 'before' as const, + action: 'insert' as const, + execute: async () => {}, + }; + expect(() => TriggerSchema.parse(trigger)).toThrow(); + }); + }); + + it('should default active to true', () => { + const trigger = { + name: 'test_trigger', + object: 'test', + timing: 'before' as const, + action: 'insert' as const, + execute: async () => {}, + }; + + const parsed = TriggerSchema.parse(trigger); + expect(parsed.active).toBe(true); + }); + + it('should default order to 0', () => { + const trigger = { + name: 'test_trigger', + object: 'test', + timing: 'before' as const, + action: 'insert' as const, + execute: async () => {}, + }; + + const parsed = TriggerSchema.parse(trigger); + expect(parsed.order).toBe(0); + }); + }); +}); + +describe('Trigger Use Cases', () => { + describe('Before Insert Trigger', () => { + it('should set default values', async () => { + const trigger: Trigger = { + name: 'set_defaults', + object: 'account', + timing: 'before', + action: 'insert', + execute: async (context: TriggerContext) => { + if (!context.doc.status) { + context.doc.status = 'active'; + } + if (!context.doc.type) { + context.doc.type = 'standard'; + } + }, + }; + + const doc = { name: 'Test Account' }; + const context: TriggerContext = { + action: 'insert', + timing: 'before', + doc, + userId: 'user123', + user: {}, + ql: {}, + logger: { info: () => {}, error: () => {} }, + addError: () => {}, + getOldValue: () => undefined, + }; + + await trigger.execute(context); + expect(context.doc.status).toBe('active'); + expect(context.doc.type).toBe('standard'); + }); + + it('should validate required fields', async () => { + let errorAdded = false; + let errorMessage = ''; + + const trigger: Trigger = { + name: 'validate_required', + object: 'contact', + timing: 'before', + action: 'insert', + execute: async (context: TriggerContext) => { + if (!context.doc.email) { + context.addError('Email is required', 'email'); + } + }, + }; + + const context: TriggerContext = { + action: 'insert', + timing: 'before', + doc: { name: 'John Doe' }, + userId: 'user123', + user: {}, + ql: {}, + logger: { info: () => {}, error: () => {} }, + addError: (message: string, field?: string) => { + errorAdded = true; + errorMessage = message; + }, + getOldValue: () => undefined, + }; + + await trigger.execute(context); + expect(errorAdded).toBe(true); + expect(errorMessage).toBe('Email is required'); + }); + }); + + describe('After Update Trigger', () => { + it('should detect field changes', async () => { + let statusChanged = false; + + const trigger: Trigger = { + name: 'detect_status_change', + object: 'opportunity', + timing: 'after', + action: 'update', + execute: async (context: TriggerContext) => { + const oldStatus = context.getOldValue('status'); + if (oldStatus !== context.doc.status) { + statusChanged = true; + } + }, + }; + + const context: TriggerContext = { + action: 'update', + timing: 'after', + doc: { id: '1', status: 'closed' }, + previousDoc: { id: '1', status: 'open' }, + userId: 'user123', + user: {}, + ql: {}, + logger: { info: () => {}, error: () => {} }, + addError: () => {}, + getOldValue: (field: string) => { + const prev: any = { id: '1', status: 'open' }; + return prev[field]; + }, + }; + + await trigger.execute(context); + expect(statusChanged).toBe(true); + }); + + it('should update related records', async () => { + const createdRecords: any[] = []; + + const trigger: Trigger = { + name: 'log_changes', + object: 'account', + timing: 'after', + action: 'update', + execute: async (context: TriggerContext) => { + await context.ql.object('activity_log').create({ + record_id: context.doc.id, + action: 'updated', + user_id: context.userId, + changes: { + old: context.previousDoc, + new: context.doc, + }, + }); + }, + }; + + const context: TriggerContext = { + action: 'update', + timing: 'after', + doc: { id: '1', name: 'Updated Name' }, + previousDoc: { id: '1', name: 'Old Name' }, + userId: 'user123', + user: {}, + ql: { + object: () => ({ + create: async (data: any) => { + createdRecords.push(data); + return data; + }, + }), + }, + logger: { info: () => {}, error: () => {} }, + addError: () => {}, + getOldValue: () => undefined, + }; + + await trigger.execute(context); + expect(createdRecords).toHaveLength(1); + expect(createdRecords[0].action).toBe('updated'); + }); + }); + + describe('After Delete Trigger', () => { + it('should clean up related records', async () => { + const deletedRecords: string[] = []; + + const trigger: Trigger = { + name: 'cleanup_children', + object: 'parent_object', + timing: 'after', + action: 'delete', + execute: async (context: TriggerContext) => { + await context.ql.object('child_object').delete({ + parent_id: context.doc.id, + }); + }, + }; + + const context: TriggerContext = { + action: 'delete', + timing: 'after', + doc: { id: 'parent123', name: 'Parent Record' }, + userId: 'user123', + user: {}, + ql: { + object: () => ({ + delete: async (query: any) => { + deletedRecords.push(query.parent_id); + return { deleted: true }; + }, + }), + }, + logger: { info: () => {}, error: () => {} }, + addError: () => {}, + getOldValue: () => undefined, + }; + + await trigger.execute(context); + expect(deletedRecords).toContain('parent123'); + }); + }); + + describe('Trigger Ordering', () => { + it('should support execution order', () => { + const trigger1: Trigger = { + name: 'first_trigger', + object: 'test', + timing: 'before', + action: 'insert', + execute: async () => {}, + order: 1, + }; + + const trigger2: Trigger = { + name: 'second_trigger', + object: 'test', + timing: 'before', + action: 'insert', + execute: async () => {}, + order: 2, + }; + + const parsed1 = TriggerSchema.parse(trigger1); + const parsed2 = TriggerSchema.parse(trigger2); + + expect(parsed1.order).toBeLessThan(parsed2.order); + }); + }); +}); diff --git a/packages/spec/src/data/trigger.zod.ts b/packages/spec/src/data/trigger.zod.ts new file mode 100644 index 000000000..3644eab93 --- /dev/null +++ b/packages/spec/src/data/trigger.zod.ts @@ -0,0 +1,220 @@ +import { z } from 'zod'; + +/** + * Trigger Action Enum + * + * Defines the database operation that triggered the execution. + */ +export const TriggerAction = z.enum(['insert', 'update', 'delete']); + +/** + * Trigger Timing Enum + * + * Defines when the trigger executes relative to the database operation. + */ +export const TriggerTiming = z.enum(['before', 'after']); + +/** + * Trigger Context Schema + * + * This defines the runtime context available to trigger code during execution. + * Standardizes how trigger code is written and enables AI code generation. + * + * Triggers are business logic hooks that execute before or after database operations. + * They can validate data, set defaults, update related records, or prevent operations. + * + * @example Before Insert Trigger + * export default { + * timing: 'before', + * action: 'insert', + * execute: async (context: TriggerContext) => { + * // Set default values + * if (!context.doc.status) { + * context.doc.status = 'active'; + * } + * + * // Validation + * if (!context.doc.email) { + * context.addError('Email is required'); + * } + * } + * }; + * + * @example After Update Trigger + * export default { + * timing: 'after', + * action: 'update', + * execute: async (context: TriggerContext) => { + * // Update related records + * if (context.getOldValue('status') !== context.doc.status) { + * await context.ql.object('activity_log').create({ + * record_id: context.doc.id, + * message: `Status changed from ${context.getOldValue('status')} to ${context.doc.status}`, + * user_id: context.userId, + * }); + * } + * } + * }; + */ +export const TriggerContextSchema = z.object({ + /** + * The database operation that triggered execution. + * One of: 'insert', 'update', 'delete' + */ + action: TriggerAction.describe('Database operation type'), + + /** + * When the trigger executes relative to the operation. + * - 'before': Execute before database operation (can modify doc, prevent operation) + * - 'after': Execute after database operation (can trigger side effects) + */ + timing: TriggerTiming.describe('Trigger execution timing'), + + /** + * The current document/record being operated on. + * + * For 'before' triggers: Can be modified to change what gets saved. + * For 'after' triggers: Contains the final saved state (read-only). + * For 'delete' triggers: Contains the record being deleted. + */ + doc: z.record(z.any()).describe('Current document/record'), + + /** + * The document state before the current operation. + * + * Only available for 'update' and 'delete' operations. + * Null for 'insert' operations. + * + * Use this to detect what changed in an update trigger. + */ + previousDoc: z.record(z.any()).optional().describe('Previous document state'), + + /** + * ID of the user performing the operation. + */ + userId: z.string().describe('Current user ID'), + + /** + * Complete user record of the user performing the operation. + * Contains fields like name, email, roles, etc. + */ + user: z.record(z.any()).describe('Current user record'), + + /** + * ObjectQL data access API. + * Use this to query or modify other records. + * + * @example + * await context.ql.object('account').findOne(context.doc.account_id); + * await context.ql.object('activity').create({ ... }); + */ + ql: z.any().describe('ObjectQL data access API'), + + /** + * Logging interface. + * Use this for debugging and auditing. + * + * @example + * context.logger.info('Trigger executed', { recordId: context.doc.id }); + * context.logger.error('Validation failed', { error }); + */ + logger: z.any().describe('Logging interface'), + + /** + * Add a validation error. + * For 'before' triggers only - prevents the operation from completing. + * + * @param message - Error message to display + * @param field - Optional field name the error relates to + * + * @example + * if (context.doc.amount < 0) { + * context.addError('Amount must be positive', 'amount'); + * } + */ + addError: z.function() + .args(z.string(), z.string().optional()) + .returns(z.void()) + .describe('Add validation error'), + + /** + * Get the old value of a field. + * Helper function for 'update' triggers to easily compare old vs new values. + * + * @param fieldName - Name of the field + * @returns Previous value of the field, or undefined if not available + * + * @example + * if (context.getOldValue('status') !== context.doc.status) { + * // Status changed + * } + */ + getOldValue: z.function() + .args(z.string()) + .returns(z.any()) + .describe('Get previous field value'), +}); + +/** + * Trigger Definition Schema + * + * Complete definition of a trigger including metadata and execution function. + */ +export const TriggerSchema = z.object({ + /** + * Unique trigger name. + */ + name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Trigger name (snake_case)'), + + /** + * Object this trigger is attached to. + */ + object: z.string().describe('Target object name'), + + /** + * Trigger timing. + */ + timing: TriggerTiming.describe('Execution timing'), + + /** + * Trigger action(s). + * Can be a single action or array of actions. + */ + action: z.union([ + TriggerAction, + z.array(TriggerAction), + ]).describe('Database operation(s) to trigger on'), + + /** + * Trigger execution function. + * Receives TriggerContext and performs the business logic. + */ + execute: z.function() + .args(TriggerContextSchema) + .returns(z.promise(z.void())) + .describe('Trigger execution function'), + + /** + * Optional description of what the trigger does. + */ + description: z.string().optional().describe('Trigger description'), + + /** + * Whether the trigger is active. + */ + active: z.boolean().default(true).describe('Is trigger active'), + + /** + * Execution order when multiple triggers are defined. + * Lower numbers execute first. + */ + order: z.number().default(0).describe('Execution order'), +}); + +/** + * TypeScript types + */ +export type TriggerAction = z.infer; +export type TriggerTiming = z.infer; +export type TriggerContext = z.infer; +export type Trigger = z.infer; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 536dda2cd..964dbd446 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -18,6 +18,7 @@ export * from './data/flow.zod'; export * from './data/dataset.zod'; export * from './data/query.zod'; export * from './data/mapping.zod'; +export * from './data/trigger.zod'; // API Protocol (Envelopes, Contracts) export * from './api/contract.zod'; @@ -32,6 +33,7 @@ export * from './ui/dashboard.zod'; export * from './ui/report.zod'; export * from './ui/action.zod'; export * from './ui/page.zod'; +export * from './ui/widget.zod'; // System Protocol (Manifest, Runtime, Constants) export * from './system/manifest.zod'; @@ -47,4 +49,6 @@ export * from './system/translation.zod'; export * from './system/constants'; export * from './system/types'; export * from './system/discovery.zod'; +export * from './system/plugin.zod'; +export * from './system/driver.zod'; diff --git a/packages/spec/src/system/driver.test.ts b/packages/spec/src/system/driver.test.ts new file mode 100644 index 000000000..d0053f5ea --- /dev/null +++ b/packages/spec/src/system/driver.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect } from 'vitest'; +import { + DriverCapabilitiesSchema, + DriverInterfaceSchema, + type DriverCapabilities, + type DriverInterface, +} from './driver.zod'; + +describe('DriverCapabilitiesSchema', () => { + it('should accept valid capabilities', () => { + const capabilities: DriverCapabilities = { + transactions: true, + joins: true, + fullTextSearch: true, + jsonFields: true, + arrayFields: true, + }; + + expect(() => DriverCapabilitiesSchema.parse(capabilities)).not.toThrow(); + }); + + it('should accept minimal capabilities', () => { + const capabilities: DriverCapabilities = { + transactions: false, + joins: false, + fullTextSearch: false, + jsonFields: false, + arrayFields: false, + }; + + expect(() => DriverCapabilitiesSchema.parse(capabilities)).not.toThrow(); + }); + + it('should require all capability flags', () => { + const incomplete = { + transactions: true, + joins: true, + // missing other fields + }; + + const result = DriverCapabilitiesSchema.safeParse(incomplete); + expect(result.success).toBe(false); + }); +}); + +describe('DriverInterfaceSchema', () => { + describe('Basic Properties', () => { + it('should require name and version', () => { + const incomplete = { + name: 'postgresql', + // missing version and other required fields + }; + + const result = DriverInterfaceSchema.safeParse(incomplete); + expect(result.success).toBe(false); + }); + + it('should accept name and version', () => { + const driver = { + name: 'postgresql', + version: '1.0.0', + find: async () => [], + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + bulkCreate: async () => [], + bulkUpdate: async () => [], + bulkDelete: async () => ({}), + syncSchema: async () => {}, + dropTable: async () => {}, + supports: { + transactions: true, + joins: true, + fullTextSearch: true, + jsonFields: true, + arrayFields: true, + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + }); + + describe('CRUD Operations', () => { + const baseDriver = { + name: 'test-driver', + version: '1.0.0', + find: async (object: string, query: any) => [], + findOne: async (object: string, id: any) => null, + create: async (object: string, data: any) => data, + update: async (object: string, id: any, data: any) => data, + delete: async (object: string, id: any) => ({ deleted: true }), + bulkCreate: async (object: string, data: any[]) => data, + bulkUpdate: async (object: string, updates: any[]) => updates, + bulkDelete: async (object: string, ids: any[]) => ({ deleted: ids.length }), + syncSchema: async (object: string, schema: any) => {}, + dropTable: async (object: string) => {}, + supports: { + transactions: false, + joins: false, + fullTextSearch: false, + jsonFields: false, + arrayFields: false, + }, + }; + + it('should accept driver with CRUD operations', () => { + expect(() => DriverInterfaceSchema.parse(baseDriver)).not.toThrow(); + }); + + it('should validate find method signature', () => { + const driver = { + ...baseDriver, + find: async (object: string, query: any) => [ + { id: '1', name: 'Record 1' }, + { id: '2', name: 'Record 2' }, + ], + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate findOne method signature', () => { + const driver = { + ...baseDriver, + findOne: async (object: string, id: any) => ({ + id: '1', + name: 'Record 1', + }), + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate create method signature', () => { + const driver = { + ...baseDriver, + create: async (object: string, data: any) => ({ + ...data, + id: 'generated-id', + created_at: new Date(), + }), + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate update method signature', () => { + const driver = { + ...baseDriver, + update: async (object: string, id: any, data: any) => ({ + id, + ...data, + updated_at: new Date(), + }), + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate delete method signature', () => { + const driver = { + ...baseDriver, + delete: async (object: string, id: any) => ({ + id, + deleted: true, + }), + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + }); + + describe('Bulk Operations', () => { + const baseDriver = { + name: 'test-driver', + version: '1.0.0', + find: async () => [], + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + bulkCreate: async () => [], + bulkUpdate: async () => [], + bulkDelete: async () => ({}), + syncSchema: async () => {}, + dropTable: async () => {}, + supports: { + transactions: false, + joins: false, + fullTextSearch: false, + jsonFields: false, + arrayFields: false, + }, + }; + + it('should validate bulkCreate method', () => { + const driver = { + ...baseDriver, + bulkCreate: async (object: string, data: any[]) => { + return data.map((item, i) => ({ + ...item, + id: `generated-${i}`, + })); + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate bulkUpdate method', () => { + const driver = { + ...baseDriver, + bulkUpdate: async (object: string, updates: any[]) => { + return updates.map(u => ({ + ...u.data, + id: u.id, + updated_at: new Date(), + })); + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate bulkDelete method', () => { + const driver = { + ...baseDriver, + bulkDelete: async (object: string, ids: any[]) => ({ + deleted: ids.length, + ids, + }), + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + }); + + describe('DDL Operations', () => { + const baseDriver = { + name: 'test-driver', + version: '1.0.0', + find: async () => [], + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + bulkCreate: async () => [], + bulkUpdate: async () => [], + bulkDelete: async () => ({}), + syncSchema: async () => {}, + dropTable: async () => {}, + supports: { + transactions: false, + joins: false, + fullTextSearch: false, + jsonFields: false, + arrayFields: false, + }, + }; + + it('should validate syncSchema method', () => { + const driver = { + ...baseDriver, + syncSchema: async (object: string, schema: any) => { + // Create table if not exists + // Alter table to match schema + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should validate dropTable method', () => { + const driver = { + ...baseDriver, + dropTable: async (object: string) => { + // DROP TABLE IF EXISTS + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + }); + + describe('Transaction Support', () => { + it('should accept driver without transaction support', () => { + const driver = { + name: 'simple-driver', + version: '1.0.0', + find: async () => [], + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + bulkCreate: async () => [], + bulkUpdate: async () => [], + bulkDelete: async () => ({}), + syncSchema: async () => {}, + dropTable: async () => {}, + supports: { + transactions: false, + joins: false, + fullTextSearch: false, + jsonFields: false, + arrayFields: false, + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + + it('should accept driver with transaction support', () => { + const driver = { + name: 'transactional-driver', + version: '1.0.0', + find: async () => [], + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + bulkCreate: async () => [], + bulkUpdate: async () => [], + bulkDelete: async () => ({}), + syncSchema: async () => {}, + dropTable: async () => {}, + beginTransaction: async () => ({ id: 'tx-123' }), + commit: async (tx: any) => {}, + rollback: async (tx: any) => {}, + supports: { + transactions: true, + joins: true, + fullTextSearch: false, + jsonFields: true, + arrayFields: false, + }, + }; + + expect(() => DriverInterfaceSchema.parse(driver)).not.toThrow(); + }); + }); + + describe('Real-World Driver Examples', () => { + it('should accept PostgreSQL-like driver', () => { + const postgresDriver: DriverInterface = { + name: 'postgresql', + version: '1.0.0', + find: async (object, query) => [], + findOne: async (object, id) => null, + create: async (object, data) => data, + update: async (object, id, data) => data, + delete: async (object, id) => ({}), + bulkCreate: async (object, data) => data, + bulkUpdate: async (object, updates) => updates, + bulkDelete: async (object, ids) => ({}), + syncSchema: async (object, schema) => {}, + dropTable: async (object) => {}, + beginTransaction: async () => ({}), + commit: async (tx) => {}, + rollback: async (tx) => {}, + supports: { + transactions: true, + joins: true, + fullTextSearch: true, + jsonFields: true, + arrayFields: true, + }, + }; + + expect(() => DriverInterfaceSchema.parse(postgresDriver)).not.toThrow(); + }); + + it('should accept MongoDB-like driver', () => { + const mongoDriver: DriverInterface = { + name: 'mongodb', + version: '1.0.0', + find: async (object, query) => [], + findOne: async (object, id) => null, + create: async (object, data) => data, + update: async (object, id, data) => data, + delete: async (object, id) => ({}), + bulkCreate: async (object, data) => data, + bulkUpdate: async (object, updates) => updates, + bulkDelete: async (object, ids) => ({}), + syncSchema: async (object, schema) => {}, + dropTable: async (object) => {}, + beginTransaction: async () => ({}), + commit: async (tx) => {}, + rollback: async (tx) => {}, + supports: { + transactions: true, + joins: false, // MongoDB has limited join support + fullTextSearch: true, + jsonFields: true, // Native JSON support + arrayFields: true, // Native array support + }, + }; + + expect(() => DriverInterfaceSchema.parse(mongoDriver)).not.toThrow(); + }); + + it('should accept Salesforce-like driver', () => { + const salesforceDriver: DriverInterface = { + name: 'salesforce', + version: '1.0.0', + find: async (object, query) => [], + findOne: async (object, id) => null, + create: async (object, data) => data, + update: async (object, id, data) => data, + delete: async (object, id) => ({}), + bulkCreate: async (object, data) => data, + bulkUpdate: async (object, updates) => updates, + bulkDelete: async (object, ids) => ({}), + syncSchema: async (object, schema) => {}, + dropTable: async (object) => {}, + supports: { + transactions: false, // Salesforce doesn't support transactions + joins: true, // SOQL supports relationships + fullTextSearch: true, // SOSL + jsonFields: false, // No native JSON type + arrayFields: false, // No native array type + }, + }; + + expect(() => DriverInterfaceSchema.parse(salesforceDriver)).not.toThrow(); + }); + + it('should accept Redis-like driver', () => { + const redisDriver: DriverInterface = { + name: 'redis', + version: '1.0.0', + find: async (object, query) => [], + findOne: async (object, id) => null, + create: async (object, data) => data, + update: async (object, id, data) => data, + delete: async (object, id) => ({}), + bulkCreate: async (object, data) => data, + bulkUpdate: async (object, updates) => updates, + bulkDelete: async (object, ids) => ({}), + syncSchema: async (object, schema) => {}, + dropTable: async (object) => {}, + supports: { + transactions: true, // Redis supports transactions + joins: false, // No join support + fullTextSearch: false, // No native full-text search + jsonFields: true, // RedisJSON module + arrayFields: true, // Redis lists + }, + }; + + expect(() => DriverInterfaceSchema.parse(redisDriver)).not.toThrow(); + }); + }); +}); diff --git a/packages/spec/src/system/driver.zod.ts b/packages/spec/src/system/driver.zod.ts new file mode 100644 index 000000000..a6a4d3982 --- /dev/null +++ b/packages/spec/src/system/driver.zod.ts @@ -0,0 +1,267 @@ +import { z } from 'zod'; + +/** + * Driver Capabilities Schema + * + * Defines what features a database driver supports. + * This allows ObjectQL to adapt its behavior based on underlying database capabilities. + */ +export const DriverCapabilitiesSchema = z.object({ + /** + * Whether the driver supports database transactions. + * If true, beginTransaction, commit, and rollback must be implemented. + */ + transactions: z.boolean().describe('Supports transactions'), + + /** + * Whether the driver supports SQL-style joins. + * If false, ObjectQL will fetch related data separately and join in memory. + */ + joins: z.boolean().describe('Supports SQL joins'), + + /** + * Whether the driver supports full-text search. + * If true, text search queries can be pushed to the database. + */ + fullTextSearch: z.boolean().describe('Supports full-text search'), + + /** + * Whether the driver supports JSON field types. + * If false, JSON data will be serialized as strings. + */ + jsonFields: z.boolean().describe('Supports JSON field types'), + + /** + * Whether the driver supports array field types. + * If false, arrays will be stored as JSON strings or in separate tables. + */ + arrayFields: z.boolean().describe('Supports array field types'), +}); + +/** + * Driver Interface Schema + * + * This is the unified interface that all database drivers must implement. + * It enables ObjectQL to work with any database (SQL, NoSQL, SaaS) through a consistent API. + * + * Drivers abstract the underlying database implementation and provide a standard + * set of CRUD, DDL, and transaction operations. + * + * @example + * const postgresDriver: DriverInterface = { + * name: 'postgresql', + * version: '1.0.0', + * supports: { + * transactions: true, + * joins: true, + * fullTextSearch: true, + * jsonFields: true, + * arrayFields: true, + * }, + * // ... implement all required methods + * }; + */ +export const DriverInterfaceSchema = z.object({ + /** + * Driver name (e.g., 'postgresql', 'mongodb', 'mysql', 'salesforce'). + */ + name: z.string().describe('Driver name'), + + /** + * Driver version following semantic versioning. + */ + version: z.string().describe('Driver version'), + + // ============================================================================ + // CRUD Operations + // ============================================================================ + + /** + * Find multiple records matching the query. + * + * @param object - Object name (e.g., 'account') + * @param query - Query object with filters, sorting, pagination + * @returns Promise resolving to array of records + * + * @example + * await driver.find('account', { + * filters: { status: 'active' }, + * sort: { created_at: 'desc' }, + * limit: 10 + * }); + */ + find: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.array(z.record(z.any())))) + .describe('Find multiple records'), + + /** + * Find a single record by ID or query. + * + * @param object - Object name + * @param idOrQuery - Record ID or query object + * @returns Promise resolving to single record or null + */ + findOne: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.record(z.any()).nullable())) + .describe('Find single record'), + + /** + * Create a new record. + * + * @param object - Object name + * @param data - Record data + * @returns Promise resolving to created record with generated fields (id, timestamps) + */ + create: z.function() + .args(z.string(), z.record(z.any())) + .returns(z.promise(z.record(z.any()))) + .describe('Create new record'), + + /** + * Update an existing record. + * + * @param object - Object name + * @param id - Record ID + * @param data - Updated fields + * @returns Promise resolving to updated record + */ + update: z.function() + .args(z.string(), z.any(), z.record(z.any())) + .returns(z.promise(z.record(z.any()))) + .describe('Update existing record'), + + /** + * Delete a record. + * + * @param object - Object name + * @param id - Record ID + * @returns Promise resolving to deletion result + */ + delete: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.any())) + .describe('Delete record'), + + /** + * Create multiple records in a single operation. + * + * @param object - Object name + * @param dataArray - Array of record data + * @returns Promise resolving to array of created records + */ + bulkCreate: z.function() + .args(z.string(), z.array(z.record(z.any()))) + .returns(z.promise(z.array(z.record(z.any())))) + .describe('Create multiple records'), + + /** + * Update multiple records in a single operation. + * + * @param object - Object name + * @param updates - Array of {id, data} objects + * @returns Promise resolving to array of updated records + */ + bulkUpdate: z.function() + .args(z.string(), z.array(z.any())) + .returns(z.promise(z.array(z.record(z.any())))) + .describe('Update multiple records'), + + /** + * Delete multiple records in a single operation. + * + * @param object - Object name + * @param ids - Array of record IDs + * @returns Promise resolving to deletion result + */ + bulkDelete: z.function() + .args(z.string(), z.array(z.any())) + .returns(z.promise(z.any())) + .describe('Delete multiple records'), + + // ============================================================================ + // DDL (Data Definition Language) Operations + // ============================================================================ + + /** + * Synchronize database schema with object definition. + * Creates or updates tables, columns, indexes to match the object schema. + * + * @param object - Object name + * @param schema - Object schema definition + * @returns Promise resolving when schema is synchronized + */ + syncSchema: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.void())) + .describe('Synchronize database schema'), + + /** + * Drop a table/collection from the database. + * + * @param object - Object name + * @returns Promise resolving when table is dropped + */ + dropTable: z.function() + .args(z.string()) + .returns(z.promise(z.void())) + .describe('Drop table/collection'), + + // ============================================================================ + // Transaction Support (Optional) + // ============================================================================ + + /** + * Begin a new database transaction. + * Required if supports.transactions is true. + * + * @returns Promise resolving to transaction handle + */ + beginTransaction: z.function() + .returns(z.promise(z.any())) + .optional() + .describe('Begin database transaction'), + + /** + * Commit the current transaction. + * Required if supports.transactions is true. + * + * @param transaction - Transaction handle from beginTransaction + * @returns Promise resolving when transaction is committed + */ + commit: z.function() + .args(z.any()) + .returns(z.promise(z.void())) + .optional() + .describe('Commit transaction'), + + /** + * Rollback the current transaction. + * Required if supports.transactions is true. + * + * @param transaction - Transaction handle from beginTransaction + * @returns Promise resolving when transaction is rolled back + */ + rollback: z.function() + .args(z.any()) + .returns(z.promise(z.void())) + .optional() + .describe('Rollback transaction'), + + // ============================================================================ + // Capabilities Declaration + // ============================================================================ + + /** + * Driver capabilities. + * Declares what features this driver supports. + */ + supports: DriverCapabilitiesSchema.describe('Driver capabilities'), +}); + +/** + * TypeScript types + */ +export type DriverCapabilities = z.infer; +export type DriverInterface = z.infer; diff --git a/packages/spec/src/system/plugin.test.ts b/packages/spec/src/system/plugin.test.ts new file mode 100644 index 000000000..d69c503cd --- /dev/null +++ b/packages/spec/src/system/plugin.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest'; +import { + PluginContextSchema, + PluginLifecycleSchema, + PluginSchema, + type PluginContextData, + type PluginLifecycleHooks, + type PluginDefinition, +} from './plugin.zod'; + +describe('PluginContextSchema', () => { + it('should accept valid plugin context', () => { + const context: PluginContextData = { + ql: {}, + os: {}, + logger: {}, + metadata: {}, + events: {}, + }; + + expect(() => PluginContextSchema.parse(context)).not.toThrow(); + }); + + it('should accept context with all required properties', () => { + const completeContext = { + ql: {}, + os: {}, + logger: {}, + metadata: {}, + events: {}, + }; + + const result = PluginContextSchema.safeParse(completeContext); + expect(result.success).toBe(true); + }); + + it('should accept context with actual implementations', () => { + const context = { + ql: { + object: (name: string) => ({ + find: async () => [], + create: async (data: any) => data, + }), + }, + os: { + getCurrentUser: async () => ({ id: 'user123' }), + getConfig: async (key: string) => 'value', + }, + logger: { + info: (message: string) => console.log(message), + error: (message: string, error?: any) => console.error(message, error), + }, + metadata: { + getObject: async (name: string) => ({}), + getFields: async (object: string) => [], + }, + events: { + on: (event: string, handler: Function) => {}, + emit: (event: string, data?: any) => {}, + }, + }; + + expect(() => PluginContextSchema.parse(context)).not.toThrow(); + }); +}); + +describe('PluginLifecycleSchema', () => { + it('should accept empty lifecycle (all hooks optional)', () => { + const lifecycle: PluginLifecycleHooks = {}; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with onInstall hook', () => { + const lifecycle: PluginLifecycleHooks = { + onInstall: async (context: PluginContextData) => { + // Installation logic + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with onEnable hook', () => { + const lifecycle: PluginLifecycleHooks = { + onEnable: async (context: PluginContextData) => { + // Enable logic + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with onDisable hook', () => { + const lifecycle: PluginLifecycleHooks = { + onDisable: async (context: PluginContextData) => { + // Disable logic + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with onUninstall hook', () => { + const lifecycle: PluginLifecycleHooks = { + onUninstall: async (context: PluginContextData) => { + // Uninstall logic + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with onUpgrade hook', () => { + const lifecycle: PluginLifecycleHooks = { + onUpgrade: async (context: PluginContextData, fromVersion: string, toVersion: string) => { + // Upgrade logic + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); + + it('should accept lifecycle with all hooks', () => { + const lifecycle: PluginLifecycleHooks = { + onInstall: async (context: PluginContextData) => { + await context.ql.object('plugin_data').syncSchema(); + }, + onEnable: async (context: PluginContextData) => { + context.logger.info('Plugin enabled'); + }, + onDisable: async (context: PluginContextData) => { + context.logger.info('Plugin disabled'); + }, + onUninstall: async (context: PluginContextData) => { + await context.ql.object('plugin_data').dropTable(); + }, + onUpgrade: async (context: PluginContextData, from: string, to: string) => { + context.logger.info(`Upgrading from ${from} to ${to}`); + }, + }; + + expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); + }); +}); + +describe('PluginSchema', () => { + it('should accept plugin with minimal metadata', () => { + const plugin: PluginDefinition = {}; + + expect(() => PluginSchema.parse(plugin)).not.toThrow(); + }); + + it('should accept plugin with id and version', () => { + const plugin: PluginDefinition = { + id: 'com.example.plugin', + version: '1.0.0', + }; + + expect(() => PluginSchema.parse(plugin)).not.toThrow(); + }); + + it('should accept complete plugin definition', () => { + const plugin: PluginDefinition = { + id: 'com.example.bi-plugin', + version: '2.1.0', + onInstall: async (context) => { + await context.ql.object('bi_report').create({ + name: 'Default Report', + type: 'chart', + }); + }, + onEnable: async (context) => { + context.events.on('record.created', async (data: any) => { + context.logger.info('Record created', data); + }); + }, + onDisable: async (context) => { + // Cleanup event handlers + }, + onUninstall: async (context) => { + await context.ql.object('bi_report').delete({ plugin_id: 'com.example.bi-plugin' }); + }, + onUpgrade: async (context, from, to) => { + if (from === '1.0.0' && to === '2.0.0') { + // Migrate data + await context.ql.object('bi_report').update( + {}, + { migrated: true } + ); + } + }, + }; + + expect(() => PluginSchema.parse(plugin)).not.toThrow(); + }); +}); + +describe('Plugin Lifecycle Scenarios', () => { + describe('Installation Flow', () => { + it('should handle plugin installation', async () => { + let installed = false; + + const plugin: PluginDefinition = { + id: 'test.plugin', + version: '1.0.0', + onInstall: async (context) => { + installed = true; + await context.ql.object('test_object').syncSchema(); + }, + }; + + const parsed = PluginSchema.parse(plugin); + expect(parsed.onInstall).toBeDefined(); + + // Simulate installation + if (parsed.onInstall) { + await parsed.onInstall({ + ql: { object: () => ({ syncSchema: async () => {} }) }, + os: {}, + logger: { info: () => {}, error: () => {} }, + metadata: {}, + events: {}, + } as any); + } + + expect(installed).toBe(true); + }); + }); + + describe('Enable/Disable Flow', () => { + it('should handle plugin enable and disable', async () => { + let enabled = false; + + const plugin: PluginDefinition = { + onEnable: async (context) => { + enabled = true; + context.logger.info('Plugin enabled'); + }, + onDisable: async (context) => { + enabled = false; + context.logger.info('Plugin disabled'); + }, + }; + + const parsed = PluginSchema.parse(plugin); + + const mockContext = { + ql: {}, + os: {}, + logger: { info: () => {}, error: () => {} }, + metadata: {}, + events: {}, + } as any; + + // Enable + if (parsed.onEnable) { + await parsed.onEnable(mockContext); + } + expect(enabled).toBe(true); + + // Disable + if (parsed.onDisable) { + await parsed.onDisable(mockContext); + } + expect(enabled).toBe(false); + }); + }); + + describe('Upgrade Flow', () => { + it('should handle version upgrade', async () => { + let upgradeCalled = false; + let upgradeFrom = ''; + let upgradeTo = ''; + + const plugin: PluginDefinition = { + onUpgrade: async (context, from, to) => { + upgradeCalled = true; + upgradeFrom = from; + upgradeTo = to; + }, + }; + + const parsed = PluginSchema.parse(plugin); + + if (parsed.onUpgrade) { + await parsed.onUpgrade( + { + ql: {}, + os: {}, + logger: { info: () => {}, error: () => {} }, + metadata: {}, + events: {}, + } as any, + '1.0.0', + '2.0.0' + ); + } + + expect(upgradeCalled).toBe(true); + expect(upgradeFrom).toBe('1.0.0'); + expect(upgradeTo).toBe('2.0.0'); + }); + }); + + describe('Uninstallation Flow', () => { + it('should handle plugin uninstallation', async () => { + let uninstalled = false; + + const plugin: PluginDefinition = { + onUninstall: async (context) => { + uninstalled = true; + await context.ql.object('test_object').dropTable(); + }, + }; + + const parsed = PluginSchema.parse(plugin); + + if (parsed.onUninstall) { + await parsed.onUninstall({ + ql: { object: () => ({ dropTable: async () => {} }) }, + os: {}, + logger: { info: () => {}, error: () => {} }, + metadata: {}, + events: {}, + } as any); + } + + expect(uninstalled).toBe(true); + }); + }); +}); diff --git a/packages/spec/src/system/plugin.zod.ts b/packages/spec/src/system/plugin.zod.ts new file mode 100644 index 000000000..94042c84b --- /dev/null +++ b/packages/spec/src/system/plugin.zod.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; + +/** + * Plugin Context Schema + * + * This defines the runtime context available to plugins during their lifecycle. + * The context provides access to core ObjectStack APIs and services. + * + * @example + * export default { + * onEnable: async (context: PluginContext) => { + * const { ql, os, logger } = context; + * logger.info('Plugin enabled'); + * await ql.object('custom_object').find({}); + * } + * }; + */ +export const PluginContextSchema = z.object({ + /** + * ObjectQL data access API. + * Provides methods to query and manipulate business objects. + * + * @example + * await context.ql.object('account').find({ status: 'active' }); + * await context.ql.object('contact').create({ name: 'John Doe' }); + */ + ql: z.any().describe('ObjectQL data access API'), + + /** + * ObjectOS system API. + * Provides access to system-level functionality and configuration. + * + * @example + * const user = await context.os.getCurrentUser(); + * const config = await context.os.getConfig('plugin.settings'); + */ + os: z.any().describe('ObjectOS system API'), + + /** + * Logging interface. + * Provides structured logging capabilities with different levels. + * + * @example + * context.logger.info('Operation completed'); + * context.logger.error('Operation failed', { error }); + */ + logger: z.any().describe('Logging interface'), + + /** + * Metadata registry. + * Provides access to system metadata like object schemas, field definitions, etc. + * + * @example + * const schema = await context.metadata.getObject('account'); + * const fields = await context.metadata.getFields('account'); + */ + metadata: z.any().describe('Metadata registry'), + + /** + * Event bus. + * Provides pub/sub capabilities for system and custom events. + * + * @example + * context.events.on('record.created', handler); + * context.events.emit('custom.event', data); + */ + events: z.any().describe('Event bus'), +}); + +/** + * Plugin Lifecycle Schema + * + * This defines the lifecycle hooks available to plugins. + * Plugins can implement any or all of these hooks to respond to lifecycle events. + * + * All hooks receive PluginContext as their first parameter. + * All hooks are optional and asynchronous. + * + * @example + * export default { + * onInstall: async (context) => { + * // Initialize database tables + * await context.ql.object('plugin_data').syncSchema(); + * }, + * + * onEnable: async (context) => { + * // Start background services + * await startScheduler(context); + * }, + * + * onDisable: async (context) => { + * // Stop background services + * await stopScheduler(); + * } + * }; + */ +export const PluginLifecycleSchema = z.object({ + /** + * Called when the plugin is first installed. + * Use this to set up initial data, create database tables, or register resources. + * + * @param context - Plugin runtime context + */ + onInstall: z.function() + .args(PluginContextSchema) + .returns(z.promise(z.void())) + .optional() + .describe('Hook called on plugin installation'), + + /** + * Called when the plugin is enabled. + * Use this to start services, register event handlers, or initialize runtime state. + * + * @param context - Plugin runtime context + */ + onEnable: z.function() + .args(PluginContextSchema) + .returns(z.promise(z.void())) + .optional() + .describe('Hook called when plugin is enabled'), + + /** + * Called when the plugin is disabled. + * Use this to stop services, unregister handlers, or clean up runtime state. + * + * @param context - Plugin runtime context + */ + onDisable: z.function() + .args(PluginContextSchema) + .returns(z.promise(z.void())) + .optional() + .describe('Hook called when plugin is disabled'), + + /** + * Called when the plugin is uninstalled. + * Use this to clean up data, remove database tables, or unregister resources. + * + * @param context - Plugin runtime context + */ + onUninstall: z.function() + .args(PluginContextSchema) + .returns(z.promise(z.void())) + .optional() + .describe('Hook called on plugin uninstallation'), + + /** + * Called when the plugin is upgraded to a new version. + * Use this to migrate data, update schemas, or handle breaking changes. + * + * @param context - Plugin runtime context + * @param fromVersion - Previous version string + * @param toVersion - New version string + */ + onUpgrade: z.function() + .args(PluginContextSchema, z.string(), z.string()) + .returns(z.promise(z.void())) + .optional() + .describe('Hook called on plugin upgrade'), +}); + +/** + * Complete Plugin Definition Schema + * + * Combines lifecycle hooks with plugin metadata. + */ +export const PluginSchema = PluginLifecycleSchema.extend({ + /** + * Plugin metadata identifier. + * Should match the id in the manifest. + */ + id: z.string().optional().describe('Plugin identifier'), + + /** + * Plugin version. + * Should match the version in the manifest. + */ + version: z.string().optional().describe('Plugin version'), +}); + +/** + * TypeScript types + */ +export type PluginContextData = z.infer; +export type PluginLifecycleHooks = z.infer; +export type PluginDefinition = z.infer; diff --git a/packages/spec/src/ui/widget.test.ts b/packages/spec/src/ui/widget.test.ts new file mode 100644 index 000000000..565045f9d --- /dev/null +++ b/packages/spec/src/ui/widget.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from 'vitest'; +import { FieldWidgetPropsSchema, type FieldWidgetProps } from './widget.zod'; +import { Field } from '../data/field.zod'; + +describe('FieldWidgetPropsSchema', () => { + describe('Valid Widget Props', () => { + it('should accept minimal valid widget props', () => { + const props: FieldWidgetProps = { + value: 'test value', + onChange: () => {}, + field: { + name: 'test_field', + type: 'text', + }, + }; + + const result = FieldWidgetPropsSchema.safeParse(props); + expect(result.success).toBe(true); + }); + + it('should accept complete widget props', () => { + const props: FieldWidgetProps = { + value: 'John Doe', + onChange: (newValue: any) => console.log(newValue), + readonly: false, + required: true, + error: 'This field is required', + field: { + name: 'full_name', + label: 'Full Name', + type: 'text', + maxLength: 100, + required: true, + }, + record: { + id: '123', + full_name: 'John Doe', + email: 'john@example.com', + }, + options: { + theme: 'dark', + placeholder: 'Enter your name', + }, + }; + + const result = FieldWidgetPropsSchema.safeParse(props); + expect(result.success).toBe(true); + }); + + it('should apply default values for readonly and required', () => { + const props = { + value: 42, + onChange: () => {}, + field: { + name: 'count', + type: 'number', + }, + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.readonly).toBe(false); + expect(result.required).toBe(false); + }); + }); + + describe('Different Value Types', () => { + it('should accept string value', () => { + const props = { + value: 'text value', + onChange: () => {}, + field: Field.text(), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept number value', () => { + const props = { + value: 42, + onChange: () => {}, + field: Field.number(), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept boolean value', () => { + const props = { + value: true, + onChange: () => {}, + field: Field.boolean(), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept array value for multiple fields', () => { + const props = { + value: ['option1', 'option2'], + onChange: () => {}, + field: { + type: 'select', + multiple: true, + options: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ], + }, + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept object value', () => { + const props = { + value: { id: '123', name: 'Test' }, + onChange: () => {}, + field: Field.lookup('account'), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept null value', () => { + const props = { + value: null, + onChange: () => {}, + field: Field.text(), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + + it('should accept undefined value', () => { + const props = { + value: undefined, + onChange: () => {}, + field: Field.text(), + }; + + expect(() => FieldWidgetPropsSchema.parse(props)).not.toThrow(); + }); + }); + + describe('Field Definition', () => { + it('should accept text field definition', () => { + const props = { + value: 'test', + onChange: () => {}, + field: Field.text({ label: 'Name', maxLength: 50 }), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.field.type).toBe('text'); + expect(result.field.label).toBe('Name'); + expect(result.field.maxLength).toBe(50); + }); + + it('should accept select field definition with options', () => { + const props = { + value: 'high', + onChange: () => {}, + field: Field.select({ + label: 'Priority', + options: [ + { label: 'High', value: 'high' }, + { label: 'Low', value: 'low' }, + ], + }), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.field.type).toBe('select'); + expect(result.field.options).toHaveLength(2); + }); + + it('should accept lookup field definition', () => { + const props = { + value: { id: 'acc123', name: 'Acme Corp' }, + onChange: () => {}, + field: Field.lookup('account', { + label: 'Account', + referenceFilters: ['status = "active"'], + }), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.field.type).toBe('lookup'); + expect(result.field.reference).toBe('account'); + }); + }); + + describe('Widget States', () => { + it('should accept readonly state', () => { + const props = { + value: 'readonly value', + onChange: () => {}, + readonly: true, + field: Field.text(), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.readonly).toBe(true); + }); + + it('should accept required state', () => { + const props = { + value: '', + onChange: () => {}, + required: true, + field: Field.text({ required: true }), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.required).toBe(true); + }); + + it('should accept error message', () => { + const props = { + value: '', + onChange: () => {}, + required: true, + error: 'This field is required', + field: Field.text({ required: true }), + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.error).toBe('This field is required'); + }); + }); + + describe('Record Context', () => { + it('should accept record context for cross-field logic', () => { + const props = { + value: 'john@example.com', + onChange: () => {}, + field: Field.email({ label: 'Email' }), + record: { + id: 'contact123', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + account_id: 'acc123', + }, + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.record).toBeDefined(); + expect(result.record?.id).toBe('contact123'); + expect(result.record?.first_name).toBe('John'); + }); + }); + + describe('Custom Options', () => { + it('should accept custom widget options', () => { + const props = { + value: '2024-01-20', + onChange: () => {}, + field: Field.date({ label: 'Birth Date' }), + options: { + format: 'YYYY-MM-DD', + minDate: '1900-01-01', + maxDate: '2024-12-31', + showCalendar: true, + }, + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.options).toBeDefined(); + expect(result.options?.format).toBe('YYYY-MM-DD'); + expect(result.options?.showCalendar).toBe(true); + }); + + it('should accept nested custom options', () => { + const props = { + value: 'rich text', + onChange: () => {}, + field: Field.html({ label: 'Content' }), + options: { + editor: { + toolbar: ['bold', 'italic', 'link'], + plugins: ['autolink', 'lists'], + }, + maxHeight: 400, + }, + }; + + const result = FieldWidgetPropsSchema.parse(props); + expect(result.options?.editor).toBeDefined(); + expect(result.options?.editor.toolbar).toContain('bold'); + }); + }); + + describe('Required Fields Validation', () => { + it('should accept props with all required fields', () => { + const props = { + value: 'test', + onChange: () => {}, + field: Field.text(), + }; + + const result = FieldWidgetPropsSchema.safeParse(props); + expect(result.success).toBe(true); + }); + + it('should require onChange function', () => { + const props = { + value: 'test', + onChange: 'not a function', // Invalid type + field: Field.text(), + }; + + const result = FieldWidgetPropsSchema.safeParse(props); + expect(result.success).toBe(false); + }); + + it('should require field definition with valid structure', () => { + const props = { + value: 'test', + onChange: () => {}, + field: 'not an object', // Invalid type + }; + + const result = FieldWidgetPropsSchema.safeParse(props); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/spec/src/ui/widget.zod.ts b/packages/spec/src/ui/widget.zod.ts new file mode 100644 index 000000000..7b6c26551 --- /dev/null +++ b/packages/spec/src/ui/widget.zod.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { FieldSchema } from '../data/field.zod'; + +/** + * Field Widget Props Schema + * + * This defines the contract for custom field components and plugin UI extensions. + * Third-party developers use this interface to build custom field widgets that integrate + * seamlessly with the ObjectStack UI system. + * + * @example + * // Custom widget implementation + * function CustomDatePicker(props: FieldWidgetProps) { + * const { value, onChange, readonly, required, error, field, record, options } = props; + * // Widget implementation... + * } + */ +export const FieldWidgetPropsSchema = z.object({ + /** + * Current field value. + * Type depends on the field type (string, number, boolean, array, object, etc.) + */ + value: z.any().describe('Current field value'), + + /** + * Callback function to update the field value. + * Should be called when user interaction changes the value. + * + * @param newValue - The new value to set + */ + onChange: z.function() + .args(z.any()) + .returns(z.void()) + .describe('Callback to update field value'), + + /** + * Whether the field is in read-only mode. + * When true, the widget should display the value but not allow editing. + */ + readonly: z.boolean().default(false).describe('Read-only mode flag'), + + /** + * Whether the field is required. + * Widget should indicate required state visually and validate accordingly. + */ + required: z.boolean().default(false).describe('Required field flag'), + + /** + * Validation error message to display. + * When present, widget should display the error in its UI. + */ + error: z.string().optional().describe('Validation error message'), + + /** + * Complete field definition from the schema. + * Contains metadata like type, constraints, options, etc. + */ + field: FieldSchema.describe('Field schema definition'), + + /** + * The complete record/document being edited. + * Useful for conditional logic and cross-field dependencies. + */ + record: z.record(z.any()).optional().describe('Complete record data'), + + /** + * Custom options passed to the widget. + * Can contain widget-specific configuration like themes, behaviors, etc. + */ + options: z.record(z.any()).optional().describe('Custom widget options'), +}); + +/** + * TypeScript type for Field Widget Props + */ +export type FieldWidgetProps = z.infer;