workspaces / workspace-xml-4Markdown ↓

Server Lua and capabilities

Keep Lua in the workspace's separate server source. Declare exported handlers in XML with a mode, and give each function one explicitly typed input. Return types are inferred when omitted. Functions use the existing exports convention. The compiler validates the source contract; the server runs handlers with the same Lua and the same ctx as every other script, and validates their inputs and outputs.

Explicit input and output contracts can embed a schema element in the urn:cococo:json-schema:1 namespace. Prefer this XML syntax when writing contracts by hand. Existing JSON text and CDATA remain supported. The XML schemas manual in Authoring Docs describes the shared syntax; handler contracts retain the restrictions below.

Typed handler contracts

A handler without input/output children derives its contract from a direct, top-level exports.name function declaration in server Lua. Inputs must be explicitly typed object types; inline records and named aliases are supported. Outputs are inferred from the checked function body unless a return annotation is supplied. Explicit return annotations are checked. Both contracts must describe objects. Type aliases, nested records, arrays, optional values, scalar types, literal enums, and discriminated object unions are supported. Objects are closed: unknown properties are rejected. Recursive types, any, maps, function-valued fields and ambiguous unions are rejected. Inference requires a bounded JSON-compatible result: unresolved any values (including untyped empty arrays) need a local or return annotation. GraphQL results are typed from your instance schema. Use one export assignment per statement when inferring outputs. Dynamic export registration requires an explicit legacy JSON Schema contract.

FormResult is built in: an object containing an errors array whose entries have a required string message and optional nullable string path. Use json.array() for an empty array and json.object() for an empty object: a plain empty table encodes as []. Wrap computed sequences with json.array(values) to preserve their inferred element type. Do not redeclare FormResult.

A handler's input keeps its JSON exactly: a null field arrives as json.null() (test it with json.isnull), and an absent field is nil. Return json.null() to send a null.

<workspace id="typed-form" title="Typed note form" role="Operations" xmlns:t="urn:cococo:dataxml">
  <handler name="validateNote" mode="action"/>
  <panel id="main" title="Note">
    <query type="graphql">query { __typename }</query>
    <form id="note" mode="create">
      <submit type="lua" handler="validateNote" errors="errors"/>
      <field path="body" label="Note" control="textarea" min-length="1" max-length="2000"/>
      <form-actions save-label="Check note"/>
    </form>
  </panel>
</workspace>
type NoteInput = { body: string }

-- This example validates a note; it deliberately performs no database writes.
function exports.validateNote(input: NoteInput): FormResult
  local errors = json.array()
  if input.body:match("^%s*$") then
    errors[1] = {path="body", message="Write a note before saving."}
  end
  return {errors=errors}
end

Field restrictions (required="true", min-length, max-length, minimum, maximum, min-items, max-items) strengthen the Lua handler input contract. They are enforced by the server on every call, including direct API calls. Restrictions from all forms using the same handler are combined before any form is compiled; contradictory bounds are an authoring error. They cannot weaken a Lua type. Optional parent objects remain optional unless explicitly required at the handler boundary. Form restrictions apply to ordinary fields, including fields in sections/fieldsets; variant-case fields continue to take constraints from their declared handler type/schema.

For arguments without a visible form field, place a field child with a path and restrictions inside the handler. GraphQL form constraints continue to come from the GraphQL contract. Explicit JSON Schema input/output pairs remain supported for existing sources and advanced contracts; supply both or neither.

Editor assistance

The Workspace editor analyzes XML and Lua together after a short typing pause, without saving drafts, running Lua, or querying business data. It displays source-specific diagnostics and supports a manual check with Cmd/Ctrl+Enter in the Lua editor. XML suggestions come from the generated component catalog. Lua suggestions include the Workspace API, named types, typed handler inputs, and inferred expression members, including GraphQL results. Hover shows types/signatures; the outline lists handlers, and definition navigation connects XML handler references to Lua declarations.

Rename refactoring and project-wide module navigation are not provided. The runtime still validates every handler call.

GraphQL results are inferred

A literal GraphQL operation passed to ctx.graphql.query is checked against the current schema. Its selected fields, aliases, fragments, list element types, nullability and variables determine the Lua call's type. Intermediate result records do not need handwritten type declarations. Immutable local query strings are supported; a variable assigned anywhere in the script, including a closure, falls back to dynamic-query checking. Passing a document through an arbitrary helper function also uses that fallback. The editor reports the loss of static operation checking rather than guessing a result shape.

Bind both ok and the response. A successful call can still contain GraphQL errors, absent data or null data: a GraphQL null arrives as Lua nil, so check data, and the fields you read, before use. A successful call does not prove that the GraphQL operation succeeded.

Interface and union selections retain their concrete alternatives. Selecting __typename enables narrowing by its literal value. Conditional selections can be absent even when the schema field is non-null. Variables follow input object, enum, default and OneOf rules; recursive input schemas use bounded named type references. Built-in scalars have known wire types. Custom scalars use their @iac_scalar(carries:) metadata; an unmapped scalar remains any.

The starter below is checked against your instance schema. Its intermediate GraphQL responses and the jobNotes output contract are inferred. Its input is typed inline, and table.map preserves the note element types while adding presentation data. The action keeps FormResult because its empty error array needs an explicit element contract. No GraphQL result records are redeclared.

-- Add a Knowledge Note attached to the selected job; never overwrite its description.
exports.addJobNote = function(input: { entityId: string, body: string }): FormResult
  local body = input.body:match("^%s*(.-)%s*$") or ""
  if body == "" then
    local errors = json.array()
    errors[1] = {path="body", message="Write a note before saving."}
    return {errors=errors}
  end
  local ok, response = ctx.graphql.query([[
    mutation AddJobNote($input: CreateKnowledgeNoteInput!) {
      result: createKnowledgeNote(input: $input) {
        knowledgeNote { id }
        errors { path message }
      }
    }
  ]], {input={kind="ENTITY", entityId=input.entityId, body=body, scope="TENANT"}})
  if not ok then error(response) end
  if response.errors and #response.errors > 0 then error(response.errors[1].message) end
  if not response.data then error("GraphQL returned no data") end
  return {errors=response.data.result.errors}
end

-- Keep storage vocabulary out of the interface.
exports.jobNotes = function(input: { id: string })
  local ok, response = ctx.graphql.query([[
    query JobNotes($id: ID!, $jobId: JobID!) {
      job: getJob(id: $jobId) { id name }
      notes: knowledgeNotes(first: 50, filter: {entityId: $id}, sort: [{field: CREATED_AT, direction: DESC}]) {
        edges { node { id body scope createdAt } }
      }
    }
  ]], {id=input.id, jobId=input.id})
  if not ok then error(response) end
  if response.errors and #response.errors > 0 then error(response.errors[1].message) end
  if not response.data then error("GraphQL returned no data") end
  return {
    job=response.data.job,
    notes={edges=json.array(table.map(response.data.notes.edges, function(edge)
      local note = edge.node
      local visibility = "Everyone"
      if note.scope == "PERSONAL" then visibility = "Only me" end
      return {node={id=note.id, body=note.body, scope=note.scope,
        createdAt=note.createdAt, visibility=visibility}}
    end))},
  }
end

The same analyzer checks Lua when you save a Workspace, validate a Script, validate Lua through MCP, or run IaC checks. It parses and validates operations without executing them, invoking resolvers, or changing authorization. Dynamic operations remain subject to ordinary runtime GraphQL validation and permissions. The request supports at most 64 distinct operations, 256 KiB per operation, 32,768 non-whitespace syntax bytes outside strings/comments, syntax nesting of 64, selection nesting of 32, and 8,192 expanded selections/types. Exceeding a bound produces a diagnostic.

The editor offers schema-driven completion and hover inside GraphQL query strings and checker-derived completion and hover on Lua results after guards and assignments. GraphQL diagnostics point into the original Lua literal, including escaped strings and long-bracket strings. Completion may be unavailable while surrounding Lua syntax is incomplete; rename/refactoring is not part of this service. Handler inputs remain explicitly typed. For a handler declared in XML without legacy JSON Schema children, the compiler derives its output contract from the checked return type, including inferred GraphQL result fields.

A read handler produces data. An action handler performs an explicit write or compound workflow. Separate GraphQL mutations are separate writes: wrapping them in one Lua handler does not create a database transaction. Use a single domain mutation when atomicity is required. Compensation is best-effort and can itself fail; report partial completion rather than claiming rollback. The GraphQL capability runs under the invoking user's permissions; a handler is not an authorization bypass. Handle transport and GraphQL errors before interpreting a successful domain payload.

This source implements the customer-workbench contact action. The complete form and handler contract appear in the recipe chapter.

local function trim(value)
  return value:match("^%s*(.-)%s*$")
end

-- Compound input rules stay on the server. GraphQL still owns persistence,
-- tenant scoping, resource permissions, and authoritative domain validation.
exports.createContact = function(input)
  local first, last = trim(input.firstName), trim(input.lastName)
  local errors = json.array()
  if first == "" then errors[#errors + 1] = {path="firstName", message="Enter a first name."} end
  if last == "" then errors[#errors + 1] = {path="lastName", message="Enter a last name."} end
  if #errors > 0 then return {record=json.null(), errors=errors} end
  local contact = {customerId=input.customerId, firstName=first, lastName=last}
  if input.email and not json.isnull(input.email) then
    contact.email = trim(input.email):lower()
  end
  local ok, response = ctx.graphql.query([[
    mutation CreateContact($input:CreateContactInput!) {
      result:createContact(input:$input) {
        contact {id firstName lastName email}
        errors {path message}
      }
    }
  ]], {input=contact})
  if not ok then error(response) end
  if response.errors and #response.errors > 0 then error(response.errors[1].message) end
  if not response.data then error("GraphQL returned no data") end
  return {record=response.data.result.contact, errors=response.data.result.errors}
end

Return the declared result shape and path-addressed errors expected by the form. Use the provided JSON helpers when an empty array or JSON null must be preserved across Lua's table/nil boundary. Do not infer a successful write solely from the absence of an exception: check the business mutation's returned errors and record.

For read computations, the KPI recipe's handler asks GraphQL for a metric series. Its output contract covers both empty and populated results.

local function checked_query(document, variables)
  local ok, response = ctx.graphql.query(document, variables)
  if not ok then error(response) end
  if response.errors and #response.errors > 0 then error(response.errors[1].message) end
  if not response.data then error("GraphQL returned no data") end
  return response.data
end

exports.addContact = function(input)
  local function trim(value) return value:match("^%s*(.-)%s*$") end
  local first, last, email = trim(input.firstName), trim(input.lastName), trim(input.email):lower()
  if first == "" or last == "" then error("First and last names are required") end
  if not email:match("^[^%s@]+@[^%s@]+%.[^%s@]+$") then error("Enter an email address") end
  local data = checked_query([[
    mutation AddContact($input: CreateContactInput!) {
      result: createContact(input: $input) { contact { id } errors { message } }
    }
  ]], {input = {customerId = input.customerId, firstName = first, lastName = last, email = email}})
  if data.result.errors and #data.result.errors > 0 then error(data.result.errors[1].message) end
  if not data.result.contact then error("Contact was not created") end
  return {contactId = data.result.contact.id}
end

exports.qualitySummary = function(input)
  return checked_query([[
    query Quality($id: MetricID!) {
      series: getMetricPoints(metricId: $id, limit: 24) { avg min max count points { value } }
    }
  ]], {id = input.metricId})
end

Source text is stored with the authoring artifact. Readers receive the compiled block definition, not the server Lua. Publication verifies the artifact against its source. Workspace authoring never runs JavaScript on the server.

Handler JSON Schema vocabulary

Ordinary schema nodes accept: additionalProperties, const, description, enum, items, maxItems, maxLength, maximum, minItems, minLength, minimum, properties, required, title, type.

Alternative nodes accept only: description, oneOf, title, type. oneOf requires 2–256 closed object branches, exactly one common required string discriminator, and unique string const values. Arbitrary schema unions are unsupported.

Schema nesting is limited to 16 levels. JSON is limited to 256 KiB and 24 levels. additionalProperties must be boolean. References, regular expressions, exclusiveMinimum, and unlisted keywords are rejected. Use inclusive minimum/maximum when they express the intended contract; enforce other business rules explicitly in the handler and return field errors.

Returning display values

The following display handler combines a name and formats a numeric amount with its currency code. This is display formatting, not money arithmetic or a locale-aware currency formatter. For schema queries, bind these returned fields just like ordinary GraphQL response fields.

<workspace id="formatted-customer" title="Customer summary" role="Sales" xmlns:t="urn:cococo:dataxml">
  <handler name="summary" mode="read">
    <input>
      <schema type="object" xmlns="urn:cococo:json-schema:1">
        <additional-properties boolean="false"/>
        <property name="amount" type="number"/>
        <property name="currency" type="string"/>
        <property name="first" type="string"/>
        <property name="last" type="string"/>
        <required name="first"/>
        <required name="last"/>
        <required name="amount"/>
        <required name="currency"/>
      </schema>
    </input>
    <output>
      <schema type="object" xmlns="urn:cococo:json-schema:1">
        <additional-properties boolean="false"/>
        <property name="balance" type="string"/>
        <property name="name" type="string"/>
        <required name="name"/>
        <required name="balance"/>
      </schema>
    </output>
  </handler>
  <state name="first" default='"Ada"'/><state name="last" default='"Lovelace"'/>
  <state name="amount" default="12.5"/><state name="currency" default='"GBP"'/>
  <panel id="summary" title="Customer">
    <query type="lua" handler="summary"><arg name="first" state="first"/><arg name="last" state="last"/><arg name="amount" state="amount"/><arg name="currency" state="currency"/></query>
    <text id="name" t:text="name"/>
    <text id="balance" t:text="balance"/>
  </panel>
</workspace>
exports.summary = function(input)
  return {
    name = input.first .. " " .. input.last,
    balance = string.format("%.2f %s", input.amount, input.currency)
  }
end

Before publishing, check how each business handler behaves with empty results, server errors and a partial write. A successful save proves the source is valid, not that the current user has permission for every write, or that separate mutations happen together.

Reporting a partial write

This action handler makes two independent writes. When customer creation succeeds but contact creation fails, the result retains the saved customer ID and reports partial success. Your workspace should offer recovery against that ID; blindly retrying the entire handler can create duplicate customers. A network failure can also leave the outcome unknown, so verify saved records before retrying. This example has no automatic compensation and promises no rollback.

<workspace id="partial-write" title="Compound write contract" role="Sales" xmlns:t="urn:cococo:dataxml">
  <handler name="onboard" mode="action">
    <input>
      <schema type="object" xmlns="urn:cococo:json-schema:1">
        <additional-properties boolean="false"/>
        <property name="firstName" type="string"/>
        <property name="lastName" type="string"/>
        <property name="name" type="string"/>
        <required name="name"/>
        <required name="firstName"/>
        <required name="lastName"/>
      </schema>
    </input>
    <output>
      <schema type="object" xmlns="urn:cococo:json-schema:1">
        <additional-properties boolean="false"/>
        <property name="complete" type="boolean"/>
        <property name="customerId" type="string"/>
        <property name="message" type="string"/>
        <required name="complete"/>
        <required name="customerId"/>
        <required name="message"/>
      </schema>
    </output>
  </handler>
  <panel id="explanation" title="Handler fixture"><query type="graphql">query { __typename }</query></panel>
</workspace>
local function record(document, variables, root, field)
  local ok, response = ctx.graphql.query(document, variables)
  if not ok then return nil, "Request failed; verify the saved records before retrying." end
  if response.errors and #response.errors > 0 then return nil, response.errors[1].message end
  local payload = response.data and response.data[root]
  if not payload then return nil, "No mutation result returned." end
  if payload.errors and #payload.errors > 0 then return nil, payload.errors[1].message end
  local value = payload[field]
  if not value then return nil, "No saved record returned." end
  return value, nil
end

exports.onboard = function(input)
  local customer, problem = record([[mutation($input:CreateCustomerInput!){createCustomer(input:$input){customer{id} errors{message}}}]], {input={name=input.name}}, "createCustomer", "customer")
  if not customer then return {complete=false, customerId="", message=problem} end
  local contact, failure = record([[mutation($input:CreateContactInput!){createContact(input:$input){contact{id} errors{message}}}]], {input={customerId=customer.id, firstName=input.firstName, lastName=input.lastName}}, "createContact", "contact")
  if not contact then
    return {complete=false, customerId=customer.id, message="Customer saved; contact not saved: " .. failure}
  end
  return {complete=true, customerId=customer.id, message="Customer and contact saved."}
end

Published platform manual · revision afba59e225d8