Skip to content
Talk to our solutions team

File fields and the content store

Binary content never lives in a row. A row holds a reference — an opaque string the block stores in a column — and the bytes live in a blob store addressed through the Content block.

Two independent mechanisms bridge to that store, and they do not talk to each other:

MechanismOpt-inRead byPurpose
type: file fieldField typeThe Data API’s own upload/download hooksPuts bytes in and streams bytes out over /data/rest
metadata.contentstoreField annotationcontent.svcRoutes an entity’s asset field to a named store and folder

A field can carry one, the other, or both. Nothing in the loader ties them together: the field-type path never reads metadata.contentstore, and the annotation lookup never checks the field’s type.

file is one of the field types in Fields; it compiles to a text column — the per-dialect column types are on Types.

entities:
- name: claim
fields:
- name: reference
type: string
modifiers: [required]
- name: scan
type: file
nullable: true

Two hooks act on file fields, both at priority 260, and both installed only when the host process registered the matching callback at boot:

HookPhaseOperationsEffect
Writeafter validatecreate, updatePasses the payload’s file bytes to the storage callback and replaces the field value with the returned reference
Readafter scanreadPasses each stored reference to the resolver callback and writes the result to a new key, <field>_url

The write hook accepts three payload shapes for a file field and ignores everything else: a map carrying name and data, raw bytes (filename synthesised as <entity>_<field>_<unix-nanos>), and a plain string — which is treated as an already-stored reference and passed through untouched.

The read hook never overwrites the stored reference. scan keeps the raw reference and scan_url is added alongside it. A resolver failure does not fail the query: the message is attached to the response metadata under file:error:<field> and the row is returned without the URL key.

POST /data/rest/{entity} accepts multipart/form-data in place of a JSON body. Dispatch is on the Content-Type prefix.

Part kindTargetRule
TextAny fieldValue is JSON-sniffed when it starts with { [ " - t f n or a digit, otherwise kept as a string
TextUndeclared fieldAccepted and passed to the engine; the validation hook decides
Filefile-typed fieldRead into {name, content_type, size, data} for the write hook
FileUndeclared field400 — the whole request fails
FileNon-file-typed field400 — the whole request fails
FileTwo or more parts on one field400 — single file per field only

The multipart parse holds 32 MiB in memory and spills the remainder to a temporary file. That cap plus the server read timeout are the only size limits: the write hook is built without a per-field maximum size and without a MIME allow-list, so neither is enforced.

Download is not a separate path. It is download=true on the single-row read, with field naming the column:

GET /data/rest/claim/id/01J8ZC...?download=true&field=scan

The handler loads only the named field and id, extracts the reference, and streams the body from the downloader callback. Response headers: Content-Type (application/octet-stream when the downloader reports none), Content-Disposition: attachment; filename="…" when a filename is known, and Content-Length when the size is known. Streaming is chunked, so response memory does not track file size.

ConditionStatusCode
No downloader registered501v2.file_downloader_unconfigured
download=true on a list path (no id)400v2.download_needs_id
field omitted400v2.missing_field
field not on the entity404v2.field_not_found
field is not file-typed400v2.field_not_file
Row not found404v2.row_not_found
Reference column holds a non-string500v2.field_unexpected_shape
Reference is empty or null404v2.field_empty
Downloader returned an error500v2.download_failed

download matches only the literal value true, in any case — not the truthy set the other request flags accept. field does nothing on any other route.

data.svc registers neither the storage callback nor the downloader callback. Both surfaces are mounted and both refuse:

RequestResult
POST /data/rest/{entity} with multipart/form-data501 v2.file_storage_unconfigured
GET /data/rest/{entity}/id/{id}?download=true501 v2.file_downloader_unconfigured

The unconfigured check runs before the download request is validated, so a malformed download request returns the 501 rather than the 400 or 404 it would otherwise earn. Multipart create is the other way round: the entity is resolved first, so v2.entity_not_found and v2.engine_unavailable can precede the 501.

With no callbacks registered, neither file hook is installed. A file field therefore behaves as a plain text column: you can write a reference string through the ordinary JSON create and read it back, and no <field>_url key is ever added. Move the bytes through the Content block and store the reference it returns.

A content store is not declared in entity YAML. There is no top-level content-store block, and the loader has no key for one. Stores arrive per tenant from the metadata stream, in the product.contentstore.data block of the tenant’s metadata response, and are parsed into a per-tenant catalogue on every refresh.

Each store binds a blob root to two of the product’s own entities. Those three names are the whole naming contract:

NameSource keyFalls back to
Store namename— required; a store without one is skipped
Asset entityassetentityname<name>_<extendassetentityname>, and extendassetentityname itself defaults to asset
Folder entityfolderentityname<name>_<extendfolderentityname>, and extendfolderentityname itself defaults to folder

A store named media with neither entity named explicitly therefore binds to media_asset and media_folder. Those entities must exist in the tenant’s schema like any others — folders are rows in the folder entity, assets are rows in the asset entity, and the bytes sit in the blob root.

KeyTypeDefaultMeaning
typestringnativenative, s3 or azureblob. Gates provisioning only — see below. It does not choose the blob backend; contentroot.type does
activebooltrueInactive stores resolve as “not found”
version.enabledboolfalseWhether the store keeps asset versions
localebooltrueWhether assets are locale-scoped
cacheexpiryduration1hAsset cache lifetime
accessmapThe store’s own access rules, separate from entity access rules

type is read in exactly two places, both of which test for native: root-folder creation and root-database creation. Root folders are created on demand — on first use of a store with no recorded root, the block looks for the folder named / and creates it if absent. A store of any other type keeps an empty root until something else provisions it.

A field opts into a store with two annotation keys under metadata::

KeyTypeMeaning
metadata.contentstorestringName of the store this field’s content belongs to
metadata.folderstringFolder path inside that store
fields:
- name: thumbnail
type: string
metadata:
contentstore: assets
folder: /thumbnails

The lookup walks the entity’s fields and returns one mapping per annotated field, each carrying the entity name, the field name, the folder path and the resolved store. Requesting a mapping for an entity that is not in the tenant’s schema fails with ENTITY_NOT_FOUND; requesting one with no schema bound fails with SCHEMA_UNAVAILABLE.

data.svc parses the annotation and carries it on the field, but nothing in data.svc reads it, and it appears in no schema export. content.svc runs the mapping lookup against its own compiled copy of the same schema, and uses the result to decide where an entry’s asset field lands.

GET /data/contentstore/list returns the calling tenant’s catalogue. It is JWT-gated and CPET-scoped. See the HTTP reference for the shared request conventions.

{
"contentstore": {
"assets": {
"name": "assets",
"type": "native",
"assetentityname": "assets_asset",
"folderentityname": "assets_folder",
"active": true,
"version": { "enabled": false },
"locale": true,
"cacheexpiry": 3600000000000,
"parent": "01J8ZC..."
}
}
}

parent is the root folder id. The blob root handle, the store’s access rules and its database handle are held in the process and are not serialised. A tenant with no registered stores gets {"contentstore": null}, not an error. A failure to read the catalogue returns 500 v2.contentstore_list_failed.

The catalogue comes from the metadata client’s config-change callback, never from the schema definitions themselves. A deployment running with no metadata service — the schemasource: {type: localdir} path in Schema sources — gets no callback, so /data/contentstore/list stays empty there.

JobOwner
Declaring file fields and metadata.contentstore annotationsData API definitions
Holding the per-tenant store catalogue, serving /data/contentstore/listdata.svc
Creating store databases, root folders, assets and folders; reading the field mappingscontent.svc
Storing and serving the bytes, transformations, versioning, localescontent.svc

data.svc deliberately does not create content-store databases. It lists what a tenant has and routes nothing.

  • Content — asset stores, folders, and the endpoints that move bytes
  • Fields — the file type and the metadata block
  • Request flagsdownload and field among the full flag set
  • Field protection — classification directives on the columns holding references