Fields
Lucent supports the following field types for defining your collection schemas.
Field Types
text / textarea
{ name: "title", type: "text", required: true }
{ name: "content", type: "textarea", required: false }
number / integer
{ name: "price", type: "number", required: true }
{ name: "quantity", type: "integer", required: true }
boolean
{ name: "published", type: "boolean", defaultValue: false }
{ name: "email", type: "email", required: true, unique: true }
password
{ name: "password", type: "password", required: true }
date
Stored as TIMESTAMPTZ. Pass and receive ISO 8601 strings.
{ name: "publishedAt", type: "date" }
select
{
name: "status",
type: "select",
options: ["draft", "published", "archived"],
defaultValue: "draft",
}
multiselect
{
name: "tags",
type: "multiselect",
options: ["news", "tech", "design", "culture"],
}
json / richtext
{ name: "metadata", type: "json" }
{ name: "body", type: "richtext" }
relationship
// Single
{ name: "author", type: "relationship", relationTo: "users" }
// Many
{ name: "categories", type: "relationship", relationTo: "categories", hasMany: true }
upload
{ name: "cover", type: "upload" }
// Restrict to images, max 5 MB
{ name: "avatar", type: "upload", allowedTypes: ["image/jpeg", "image/png"], maxSize: 5 * 1024 * 1024 }
See Storage for upload endpoint configuration.
array
An array field stores a list of structured objects. Each item is validated against the fields sub-schema.
{
name: "variants",
type: "array",
fields: [
{ name: "color", type: "text", required: true },
{ name: "size", type: "select", options: ["S", "M", "L", "XL"], required: true },
{ name: "stock", type: "integer", required: true },
{ name: "price", type: "number", required: true },
],
}
Array items are stored as JSONB (Postgres) or native arrays (SurrealDB / MongoDB). Each item must be a plain object whose keys match the sub-field names.
minItems / maxItems constraints:
{
name: "variants",
type: "array",
minItems: 1, // at least one variant required
maxItems: 10, // no more than 10 allowed
fields: [
{ name: "color", type: "text", required: true },
{ name: "size", type: "select", options: ["S", "M", "L", "XL"], required: true },
{ name: "stock", type: "integer", required: true },
{ name: "price", type: "number", required: true },
],
}
Validation rules:
- The top-level value must be an array.
- If
minItemsis set and the array is shorter, the request is rejected. - If
maxItemsis set and the array is longer, the request is rejected. - Each element must be a plain object (not null, not another array).
- Every sub-field runs its own type validation — including
required,selectoption checks, email format, etc. - Error messages include the failing item index:
Item 1 in 'variants': Field 'size' must be one of: S, M, L, XL.
Field Options
| Option | Type | Description |
|---|---|---|
name | string | Field identifier (alphanumeric + underscore) |
type | FieldType | One of the types listed above |
required | boolean | Reject null / undefined values |
unique | boolean | Add a unique constraint (not supported on JSON types) |
defaultValue | unknown | Value used when the field is omitted on create |
readonly | boolean | Prevent updates to this field after creation |
hidden | boolean | Exclude field from API responses |
description | string | Human-readable hint shown in the admin UI |
validate | (value) => true | string | Custom synchronous validation function |
Field-Level Access Control
Every field accepts an access object with read, create, and update callbacks. Each callback receives the same AccessContext as collection-level access functions and must return a boolean (or a Promise<boolean>).
| Callback | When it runs | Effect when false |
|---|---|---|
read | GET list / GET by ID / any read response | Field is omitted from the response |
create | POST (create document) | Field is silently stripped from input |
update | PATCH / PUT (update document) | Field is silently stripped from input |
When no access is defined the field is readable and writable by anyone who can access the collection.
import { defineCollection } from "@codesordinatestudio/lucent";
export const Users = defineCollection({
slug: "users",
auth: true,
fields: [
{ name: "name", type: "text", required: true },
{ name: "email", type: "email", required: true, unique: true },
{
name: "role",
type: "select",
options: ["admin", "editor", "viewer"],
defaultValue: "viewer",
// Only admins can set or change the role field
access: {
create: ({ user }) => user?.role === "admin",
update: ({ user }) => user?.role === "admin",
},
},
{
name: "date_of_birth",
type: "date",
// The user themselves or an admin can read/write; everyone else gets the field omitted
access: {
read: ({ user, doc }) => user?.role === "admin" || user?.id === doc?.id,
create: ({ user }) => user?.role === "admin" || user?.collection === "users",
update: ({ user, doc }) => user?.role === "admin" || user?.id === doc?.id,
},
},
{
name: "internalNotes",
type: "textarea",
// Only admins see or write this field — never exposed to regular users
access: {
read: ({ user }) => user?.role === "admin",
create: ({ user }) => user?.role === "admin",
update: ({ user }) => user?.role === "admin",
},
},
{
name: "password",
type: "password",
hidden: true, // hidden: true is a shorthand to always omit from reads (no access callback needed)
},
],
});
How it works
- Reads:
filterReadableFields()is called on every document before it leaves the API. Fields whereaccess.readreturnsfalseare omitted. Fields markedhidden: trueare always omitted without calling the callback. - Writes:
filterWritableFields()strips any fields whereaccess.create/access.updatereturnsfalsebefore the document reaches the database.readonlyandcomputefields are also always stripped on writes. - Fast path: If no field in the collection defines an
accesscallback, Lucent skips async evaluation entirely and uses the pre-computed visible-field list.
Custom Validation
{
name: "username",
type: "text",
required: true,
validate: (value) => {
if (typeof value === "string" && value.length < 3) {
return "Username must be at least 3 characters";
}
return true;
},
}
Computed Fields
Use compute to derive a runtime value that is not stored in the database:
{
name: "fullName",
type: "text",
compute: (doc) => `${doc.firstName} ${doc.lastName}`,
}