Fields & representations

Layer: Backend · data shapes (the fields inside a resource)

Fields are a resource’s columns. A representation is a named field-set — the shape an op returns or accepts. You write the field lists in jsonnet; codegen emits the pydantic schemas, the TypeScript types, and a scaffolded field catalog you fill in with how each column renders.

How it works

Each representation becomes a pydantic schema and a TypeScript type. On the frontend, codegen scaffolds a field catalog once — you fill in how each column renders.

The config you write

You writebe/config/inventory_resources/asset.jsonnet (field types + representations):

local fields = import "be/fields.libsonnet";

{
  representations: [
    {
      name: "default",              // lean cross-resource shape: refs, autocomplete, saved views
      exclude_actions: true,        // omit the per-row `actions` envelope
      fields: [
        fields.id(),                                                   // { name: "id", type: "uuid" }
        { name: "name", type: "str" },
        { name: "serial_number", type: "str" },
        { name: "status", type: "enum", enum: "inventory.models.AssetStatus" },  // see enums.md
      ],
    },
    {
      name: "resource",             // GET /assets/{id}
      fields: [
        fields.id(),
        { name: "name", type: "str" },
        { name: "location", type: "str", nullable: true },             // nullable -> `T | None`
        { name: "documentation_url", type: "str", nullable: true },
        { name: "status", type: "enum", enum: "inventory.models.AssetStatus" },
        { name: "category_id", type: "uuid" },
        // Many-to-one dump; joined = one SQL JOIN (right pick for scalars).
        // selectin is the default; pass many=true for collections.
        fields.nested("category", "inventory.models.Category", [
          fields.id(),
          { name: "name", type: "str" },
        ], load="joined"),
      ],
    },
    { name: "list_item", fields: [/* rows for POST /assets/search */] },
  ],

  // Map a representation to a role. `default` = the lean shape above;
  // `opa` = the projection shipped to OPA on every check (see permissions.md).
  representation_roles: { default: "default", opa: "authz" },
}

Field-list helpers, from be/fields.libsonnet — usable anywhere a fields: list appears:

fields.id(),                          // uuid PK; fields.id("int") for integer PKs
fields.timestamps(),                  // splices created_at + updated_at datetimes

// Exact fixed-point. Renders as fsh_lib.numeric.DecimalString: exact in Python,
// crosses the wire as a precision-safe *string* (TS string). precision/scale
// bound it like NUMERIC(p, s); scale requires precision.
fields.decimal("unit_price", precision=12, scale=2, nullable=false),

// Frozen-dataclass column exposed to the API as a typed pydantic schema.
fields.composite("location", {
  schema_module: "fsh_lib.geo",  schema_class: "CoordinateSchema",
  column_value_module: "codegen_database.types",  column_value_class: "Coordinate",
}, nullable=true),

// Related-model dump. many=true for collections, load="joined"|"selectin".
fields.nested("tasks", "fsh.models.Task", [fields.id(), { name: "title", type: "str" }], many=true),

Primitive types seen across the example app: str bool int float uuid datetime enum json decimal nested composite. enum needs a dotted enum: path (enums); a rep tagged opa/comms_summary feeds permissions / emails and templates.

The frontend codegen emits

Each representation becomes a TypeScript type — regenerated every run:

Generatedfe/src/_generated/types/inventory/asset.gen.ts (do not edit):

export type ListItemAssetResource = {
  type?: "asset";
  id: string;
  name: string;
  serial_number: string;
  status: AssetStatus;                 // the enum union, from the enum field
  category: ListItemAssetResourceCategoryNested;   // the nested dump
  actions: Array<AssetObjectPermission | AssetCollectionPermission>;
};

export type ResourceAssetResource = {  // the `resource` rep: adds location, documentation_url, ...
  /* ...scalars... */
  category: ResourceAssetResourceCategoryNested;
  actions: Array<AssetObjectPermission | AssetCollectionPermission>;
};

Codegen then scaffolds a field catalog once (if_exists=skip); you own it after. This is where the render utilities live — Badge + an enum display’s badgeProps, formatDate, formatNumber, internal/external to links, and detail-only fields typed off the detail rep:

Scaffolded, safe to editfe/src/fields/inventory/asset.tsx:

import { Badge, defineFields, formatDate, formatNumber } from "@fsh/components-library";

import { assetStatus } from "../../enums/assetStatus";
import { urls } from "../../routes";

import type {
  ListItemAssetResource,
  ResourceAssetResource,
} from "../../_generated/types/inventory/asset.gen";

// Cells default to the list row (ListItemAssetResource) — most fields need no
// per-cell type. A detail-only field annotates its own read off the detail rep.
export const assetFields = defineFields<ListItemAssetResource>()(
  (field) => [
    field("name", {
      header: "Name",
      cell: (row) => row.name,
      table: { rowHeader: true, sortable: true, resizable: true, width: "lg" },
    }),
    field("status", {
      header: "Status",
      // Badge colour + label come from the enum display, not hand-written here.
      cell: (row) => <Badge {...assetStatus.badgeProps(row.status)} />,
      table: { resizable: true, width: "sm" },
    }),
    field("category", {
      header: "Category",
      cell: (row) => row.category.name,                     // flatten the nested dump
      to: (row) => urls.categories.detail.href({ id: row.category.id }),  // internal link
      table: { resizable: true, width: "md" },
    }),
    field("documentation_url", {
      header: "Documentation",
      // Detail-only: pick the field off the detail rep (not on the list row).
      cell: (row: Pick<ResourceAssetResource, "documentation_url">) =>
        row.documentation_url ? "Product manual" : null,
      to: (row: Pick<ResourceAssetResource, "documentation_url">) => row.documentation_url,
      external: true,                                        // external link
    }),
    field("acquired_sample", {
      header: "Acquired",
      cell: () => formatDate("2026-06-11T12:00:00", "localeDate"),  // date formatting util
    }),
    field("replacement_value_sample", {
      header: "Replacement value",
      cell: () => formatNumber(1250.5, { currency: "USD" }),       // number/currency util
    }),
  ],
  // Command-palette / card summary: which fields form the title + subtitle
  // (the status entry reuses its Badge cell above).
  (f) => ({ title: f.name, subtitle: [f.serial_number, f.status] }),
);

A computed cell can run its own query — e.g. user.tsx resolves role ids to names with useListRoles and renders <TextBits items={...} /> (non-suspense so a cell never suspends). How the catalog feeds columns, sorting, and gating is table configuration.