Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions apps/docs/content/docs/dev/content-engine/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Content Engine provides field descriptors exported from `@vitnode/core/content`
| `field.user()` | `integer()` (FK to `core_users`) | `z.number()` | User Picker Combobox |
| `field.user({ multiple: true })` | Junction table (FK to `core_users`) | `z.array(z.number())` | Multi-select People Combobox |
| `field.slug()` | `varchar(length)` | `z.string()` | Slug Input + Auto-Generator |
| `field.file()` | `integer()` (FK to `core_files`, `ON DELETE RESTRICT`) | `z.number()` | Uploader (drag & drop + picker) |

---

Expand Down Expand Up @@ -131,6 +132,253 @@ categories: field.relation({ min: 1, multiple: true, target: () => categoryType
</Callout>
</Step>

<Step>
### Step 5: Attach a File

`field.file()` stores **one** file, by reference: the column is an `integer`
foreign key into `core_files` with `ON DELETE RESTRICT`, and nothing about the
file - not the name, not the URL, not the storage key - is copied onto the
content row.

```ts title="src/content/article.ts"
export const articleContentType = defineContentType({
id: "example.article",
tableName: "example_articles",
fields: {
title: field.text({ required: true }),
coverImage: field.file({ // [!code ++:9]
maxBytes: 5 * 1024 * 1024, // 5 MB - required
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
],
}),
},
});
```

You need no `references` entry for it. There is one files table in an
installation, so the engine resolves it for you - exactly as it does for
`field.user()`.

#### `maxBytes` - mandatory, always

<Callout type="warn" title="There is intentionally no unlimited upload">
Every file field must state its ceiling. `field.file({})` does not compile, and a
value that is zero, negative, fractional or infinite is refused at **definition
time** - when your plugin is imported, not when somebody uploads:

```ts
field.file({ maxBytes: 0 }); // ✗ must be greater than zero
field.file({ maxBytes: 1.5 }); // ✗ not a whole number of bytes
field.file({ maxBytes: 5 * 1024 * 1024 }); // ✓
```

A default here would be a number nobody chose, applied to every field in every
plugin - and the ceiling is the only thing between a form and an upload that
fills your disk.
</Callout>

#### `allowedExtensions` - what the file is *called*

Extensions are case-insensitive and normalised to lowercase with a leading dot,
so `GIF`, `.gif` and `.Gif` are **one** rule:

```ts
animation: field.file({
maxBytes: 10 * 1024 * 1024,
allowedExtensions: [".gif"],
allowedMimeTypes: ["image/gif"],
})
```

An empty array, an empty string, a bare `"."` and a compound `".tar.gz"` are all
definition-time errors. (`.tar.gz` because only the last segment is ever read, so
the rule would silently match nothing.)

#### `allowedMimeTypes` - what the bytes are *declared* to be

A separate rule, not the same one spelled twice. **If both are configured, both
must match:**

| File name | Declared type | GIF-only field |
| :--- | :--- | :--- |
| `banner.gif` | `image/gif` | ✅ accepted |
| `banner.png` | `image/png` | ❌ wrong extension *and* wrong type |
| `banner.gif` | `image/png` | ❌ a PNG renamed to `.gif` |
| `banner.png` | `image/gif` | ❌ right bytes, wrong name |

That third row is the whole point. **Never rely on the extension alone** - it is
a string somebody chose. For a strict field, state both.

```ts
document: field.file({
maxBytes: 20 * 1024 * 1024,
allowedExtensions: [".pdf"],
allowedMimeTypes: ["application/pdf"],
})
```

#### Image processing changes the stored format

With [`storage.image`](/docs/dev/storage#image-optimization) configured, VitNode
re-encodes uploaded images to WebP - so a `hero.png` is *stored* as `hero.webp`.
Include the converted format in both allowlists, or the upload route will refuse
the file it just created and tell you why:

```ts
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
```

GIF and SVG are never re-encoded, which is why a GIF-only field needs no extra
entries.

#### The AdminCP shows the constraints, always

Every generated file field renders its allowed formats and its maximum size
above the drop zone - not only when something goes wrong:

```text
Cover image

JPG, JPEG, PNG, WEBP, AVIF
Maximum file size: 5 MB

┌──────────────────────────────┐
│ Drop a file here │
│ or │
│ [ Choose file ] │
└──────────────────────────────┘
```

The formats and the size come **from the field descriptor** - the same object the
API validates against - so the screen cannot advertise a rule the server does not
enforce. Extensions are preferred over media types in that line, because
`JPG, PNG, WEBP` is what a person recognises and `image/jpeg, image/png` is not.
The native picker's `accept` gets both (`.gif,image/gif`), which is UX only: the
server checks everything again.

#### Uploads never go through a Server Action

<Callout type="warn" title="Binary data uses multipart, not Server Actions">
Content Engine binary uploads use TanStack Query and multipart API routes.
Next.js Server Actions must **not** be used for file transfer.
</Callout>

The flow is:

```text
AutoFormFile
→ TanStack Query useMutation()
→ FormData
→ POST /api/{pluginId}/admin/content/{module}/uploads/{field}
→ StorageModel.upload()
→ core_files
→ field.onChange(file.id)
```

The upload route is generated for you, once per content type, addressed by field
name. It resolves the field, requires `can_create` **or** `can_edit`, enforces
`maxBytes`, `allowedMimeTypes` and `allowedExtensions`, stores the file through
the ordinary storage adapter, and returns a normalised descriptor:

```json
{
"id": 42,
"name": "cover.webp",
"url": "https://…",
"mimeType": "image/webp",
"size": 245123,
"width": 1600,
"height": 900
}
```

The content mutation that follows is ordinary JSON:

```json
{ "coverImage": 42 }
```

No base64, no binary through a Server Action, and the form value is the same size
whether the image is 4 KB or 4 MB.

#### What a refused upload says

Every refusal comes back as JSON with a machine-readable `code` and a sentence
written for the person who picked the file:

```json
{
"code": "CONTENT_FILE_TOO_LARGE",
"message": "This file is 9 MB. The maximum is 5 MB."
}
```

| Code | Means |
| :--- | :--- |
| `CONTENT_FILE_TOO_LARGE` | Over the field's `maxBytes`. |
| `CONTENT_FILE_MIME_TYPE_NOT_ALLOWED` | The declared media type is not in `allowedMimeTypes`. |
| `CONTENT_FILE_EXTENSION_NOT_ALLOWED` | The file name's extension is not in `allowedExtensions`. |
| `CONTENT_FILE_INVALID` | The bytes could not be read - a truncated or corrupt image. |
| `CONTENT_FILE_STORAGE_UNAVAILABLE` | No storage adapter configured, or the image pipeline failed to load. **A configuration fault, not a bad file.** |
| `CONTENT_FILE_FIELD_UNKNOWN` | The URL named a field this content type has not got, or one that is not a `file`. |
| `CONTENT_FILE_FORBIDDEN` | The role may view this content type but not write it. |

The uploader restates the first three in the reader's own language, using the
field's own limits. Everything else is shown **verbatim**, and deliberately:
"Storage provider not found" is what an admin needs to read, and replacing it
with "please try again" is how somebody retries a misconfiguration for ten
minutes.

<Callout type="info" title="A 413 never reaches the route">
If your hosting platform or proxy rejects the request body before it arrives,
no `maxBytes` check runs and there is no JSON to read - the uploader says the
file was refused as too large *before* it reached the server, so the limit to
raise is the platform's rather than the field's.
</Callout>

#### Uploading is not the same as assigning

A successful upload proves the file was valid **for the field it was uploaded
for**. When a mutation arrives carrying `{ "coverImage": 42 }`, the engine reads
that `core_files` row and checks it again - it exists, it is within `maxBytes`,
its media type is allowed, its extension is allowed. So this is refused:

```ts
animation: field.file({
maxBytes: 10 * 1024 * 1024,
allowedExtensions: [".gif"],
allowedMimeTypes: ["image/gif"],
})
```

```json
{ "animation": 42 } // 42 is the PDF you uploaded for `document` → 400
```

#### What a file field may not be

| | |
| :--- | :--- |
| `localized: true` | ❌ refused - a file is one object with one storage key. Pair it with a localized `field.text()` for the alt text. |
| `multiple` | Not in this release. One file per field. |
| orderable / `titleField` / `colorField` | ❌ refused - the column holds an upload order, which is not something anybody chose. |
| filterable / searchable | ❌ refused by kind. |
| `publicApi.fields` | ✅ allowed - it crosses as the descriptor above, never as the identifier, and never with the storage key, the uploader or the metadata bag. |

#### List columns

A file field named in `admin.list.columns` renders a thumbnail (for an image) or a
file icon, plus the stored file name - never the raw identifier. A
`columns.<name>.cell` override in `buildPlugin` still wins.

</Step>

</Steps>

---
Expand All @@ -143,3 +391,4 @@ For nested structures, arrays, relationships, and multi-language support:
- **`field.structured()`**: Stores nested Zod objects in a Postgres `jsonb` column.
- **`field.repeatable()`**: Stores arrays of items in a Postgres `jsonb` column.
- **`localized: true`**: Marks text or textarea fields for translation. See [Localization](/docs/dev/content-engine/localization).
- **`field.file()`**: References one stored file. See [Storage](/docs/dev/storage#content-engine-file-fields) for how the row, `core_files` and the adapter fit together.
Loading
Loading