Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Type Safety

The gRPC API uses google.protobuf.Struct for document fields — a generic JSON object with no schema at the proto level. This is a deliberate design choice: Lua files define schemas, the proto stays stable, and the binary never needs recompiling when you add a field.

But Struct means your gRPC client sees fields as an untyped map. This page explains how to get type safety back.

The Two-Layer Architecture

┌──────────────────────────────────────────────────┐
│  Lua definitions (source of truth)               │
│  collections/posts.lua → fields, types, options  │
└────────────┬─────────────────────┬───────────────┘
             │                     │
    ┌────────▼────────┐   ┌───────▼────────────┐
    │  DescribeCollection │   │  crap-cms typegen     │
    │  (runtime, gRPC)    │   │  (build-time, Lua)   │
    └────────┬────────┘   └───────┬────────────┘
             │                     │
    ┌────────▼────────┐   ┌───────▼────────────┐
    │  Client codegen │   │  types/crap.lua     │
    │  TS/Go/Python   │   │  types/generated.lua│
    │  typed wrappers │   │  (IDE types for     │
    │                 │   │   hooks & init.lua) │
    └─────────────────┘   └────────────────────┘

Layer 1: Runtime schema discovery — the DescribeCollection RPC returns the full field schema. gRPC clients call it at startup or build time to generate typed wrappers.

Layer 2: Lua typegen — the crap-cms typegen command writes types/crap.lua (API surface types) and types/generated.lua (per-collection types) with LuaLS annotations. This gives you autocompletion and type checking inside hooks and init.lua.

DescribeCollection

The DescribeCollection RPC returns the full schema for any collection or global:

grpcurl -plaintext -d '{"slug": "posts"}' \
    localhost:50051 crap.ContentAPI/DescribeCollection

Response:

{
  "slug": "posts",
  "singularLabel": "Post",
  "pluralLabel": "Posts",
  "timestamps": true,
  "fields": [
    {
      "name": "title",
      "type": "text",
      "required": true,
      "unique": true
    },
    {
      "name": "slug",
      "type": "text",
      "required": true,
      "unique": true
    },
    {
      "name": "status",
      "type": "select",
      "required": true,
      "options": [
        { "label": "Draft", "value": "draft" },
        { "label": "Published", "value": "published" },
        { "label": "Archived", "value": "archived" }
      ]
    },
    {
      "name": "content",
      "type": "richtext"
    },
    {
      "name": "author",
      "type": "relationship",
      "relationshipCollection": "users",
      "relationshipMaxDepth": 1
    },
    {
      "name": "tags",
      "type": "relationship",
      "relationshipCollection": "tags",
      "relationshipHasMany": true
    }
  ]
}

FieldInfo Schema

Each field in the response has:

FieldTypeDescription
namestringColumn name
typestringField type: text, number, select, relationship, etc.
requiredboolWhether the field is required
uniqueboolWhether the field has a uniqueness constraint
optionsSelectOptionInfo[]Options for select fields (label + value)
relationship_collectionstring?Target collection slug for relationship fields
relationship_has_manybool?Whether it’s a many-to-many relationship
relationship_max_depthint?Per-field population depth cap
fieldsFieldInfo[]Sub-fields for array and group types (recursive)

Building Typed Clients

The idea: call DescribeCollection once (at build time or app startup), then generate typed wrappers for your language.

TypeScript Example

Call DescribeCollection for each collection and generate interfaces:

// Generated from DescribeCollection("posts")
interface Post {
  id: string;
  title: string;
  slug: string;
  status: "draft" | "published" | "archived";
  content?: string;
  author?: string;        // relationship ID (depth=0)
  tags?: string[];         // has_many relationship IDs
  created_at?: string;
  updated_at?: string;
}

interface CreatePostInput {
  title: string;           // required
  slug: string;            // required
  status: string;          // required
  content?: string;
  author?: string;
  tags?: string[];
}

The mapping from FieldInfo.type to TypeScript types:

function fieldTypeToTS(field: FieldInfo): string {
  switch (field.type) {
    case "text":
    case "textarea":
    case "richtext":
    case "email":
    case "date":
    case "slug":
      return "string";
    case "number":
      return "number";
    case "checkbox":
      return "boolean";
    case "json":
      return "unknown";
    case "select":
      return field.options.map(o => `"${o.value}"`).join(" | ");
    case "relationship":
      return field.relationshipHasMany ? "string[]" : "string";
    case "array":
      // Recurse into sub-fields
      return `Array<{ ${field.fields.map(f =>
        `${f.name}${f.required ? '' : '?'}: ${fieldTypeToTS(f)}`
      ).join('; ')} }>`;
    default:
      return "unknown";
  }
}

A typed wrapper around the gRPC client:

// Wrap the untyped gRPC client with generated types
class PostsClient {
  constructor(private client: ContentAPIClient) {}

  async find(query?: FindQuery): Promise<{ documents: Post[]; total: number }> {
    const resp = await this.client.find({ collection: "posts", ...query });
    return {
      documents: resp.documents.map(d => ({ id: d.id, ...d.fields } as Post)),
      total: resp.total,
    };
  }

  async create(data: CreatePostInput): Promise<Post> {
    const resp = await this.client.create({ collection: "posts", data });
    return { id: resp.document.id, ...resp.document.fields } as Post;
  }
}

Go Example

Same pattern — DescribeCollection at build time, generate structs:

// Generated from DescribeCollection("posts")
type Post struct {
    ID        string  `json:"id"`
    Title     string  `json:"title"`
    Slug      string  `json:"slug"`
    Status    string  `json:"status"`
    Content   *string `json:"content,omitempty"`
    Author    *string `json:"author,omitempty"`
    CreatedAt *string `json:"created_at,omitempty"`
    UpdatedAt *string `json:"updated_at,omitempty"`
}

// Convert a generic Document to a typed Post
func DocumentToPost(doc *crap.Document) Post {
    p := Post{ID: doc.Id}
    if f := doc.Fields.Fields; f != nil {
        if v, ok := f["title"]; ok {
            p.Title = v.GetStringValue()
        }
        // ...
    }
    return p
}

Python Example

# Generated from DescribeCollection("posts")
from dataclasses import dataclass
from typing import Optional, List

@dataclass
class Post:
    id: str
    title: str
    slug: str
    status: str  # "draft" | "published" | "archived"
    content: Optional[str] = None
    author: Optional[str] = None
    tags: Optional[List[str]] = None
    created_at: Optional[str] = None
    updated_at: Optional[str] = None

def document_to_post(doc) -> Post:
    fields = dict(doc.fields)
    return Post(
        id=doc.id,
        title=fields.get("title", {}).string_value,
        slug=fields.get("slug", {}).string_value,
        status=fields.get("status", {}).string_value,
        content=fields.get("content", {}).string_value or None,
        # ...
    )

Lua Typegen (for Hooks)

The gRPC type safety story above is for external clients. For Lua hooks and init.lua, the built-in typegen provides IDE-level type safety.

Generate Types

Types are auto-generated on every server startup. You can also generate them explicitly:

crap-cms typegen

This writes <config_dir>/types/generated.lua with LuaLS annotations derived from your Lua collection definitions. Use -l all to generate types for all supported languages (Lua, TypeScript, Go, Python, Rust).

What Gets Generated

For each collection, typegen emits:

TypePurpose
crap.data.PostsInput fields (for Create/Update data)
crap.doc.PostsFull document (fields + id + timestamps)
crap.hook.PostsTyped hook context (collection, operation, data)
crap.find_result.PostsFind result (documents[] + total)
crap.filters.PostsFilter keys for queries
crap.query.PostsQuery options (filters, order_by, limit, offset)
crap.hook_fn.PostsHook function signature

For globals: crap.global_data.*, crap.global_doc.*, crap.hook.global_*.

For array fields: crap.array_row.* with the sub-field types.

Select fields become union types: "draft" | "published" | "archived".

Function overloads are generated so crap.collections.find("posts", ...) returns crap.find_result.Posts instead of the generic crap.FindResult.

IDE Setup

Add a .luarc.json in your config directory:

{
  "runtime": { "version": "Lua 5.4" },
  "workspace": { "library": ["./types"] }
}

LuaLS (used by VS Code, Neovim, etc.) will then provide:

  • Autocompletion on all document fields
  • Type checking for field values
  • Inline errors for typos and type mismatches
  • Hover documentation showing field types
  • Smart overloads on crap.collections.find() per collection

Example Generated Output

For a posts collection with title, slug, status (select), content (richtext):

---@class crap.data.Posts
---@field title string
---@field slug string
---@field status "draft" | "published" | "archived"
---@field content? string

---@class crap.doc.Posts
---@field id string
---@field title string
---@field slug string
---@field status "draft" | "published" | "archived"
---@field content? string
---@field created_at? string
---@field updated_at? string

---@class crap.hook.Posts
---@field collection "posts"
---@field operation "create" | "update"
---@field data crap.data.Posts

Why Generic Struct?

The Document.fields is google.protobuf.Struct (not per-collection messages) because:

  1. Single binary — the proto file is compiled into the binary. Per-collection proto messages would require recompilation when schemas change.
  2. Lua is the schema source — schemas live in Lua files, not proto definitions. The proto layer is a transport, not a schema system.
  3. Dynamic schemas — collections can be added, removed, or modified by editing Lua files without touching the binary or proto.
  4. DescribeCollection fills the gap — runtime schema discovery gives clients everything they need to build typed wrappers, without coupling the proto to specific schemas.