Rayfin

@microsoft/rayfin-core

Complete decorator reference for @microsoft/rayfin-core — entities, field types, relationships, and permissions that generate Data API Builder configuration.

@microsoft/rayfin-core provides the decorators you apply to TypeScript classes in rayfin/data/ to define your data model. Decorators store metadata at runtime via Symbol.metadata; the Rayfin CLI reads that metadata to generate Data API Builder (DAB) configuration when you run rayfin up or rayfin up db apply.

Installation

npm install @microsoft/rayfin-core

npm create @microsoft/rayfin@latest and rayfin init configure this for you automatically. If you are adding Rayfin to an existing project, configure both manually.

TC39 Stage 3 decorators

Rayfin's decorators use the TC39 Stage 3 decorators proposal, not the older experimental decorators used by frameworks like NestJS or Angular.

  • Never enable experimentalDecorators or emitDecoratorMetadata in tsconfig.json.
  • Include ESNext.Decorators in the lib array.
  • No reflect-metadata import is required. Metadata is stored on the class itself via the native Symbol.metadata object, which the CLI and client SDKs read directly.
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "ESNext.Decorators"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Warning

Bundlers that compile decorators to an older target silently break Rayfin's metadata. If you see Expression expected errors on @entity() or other decorators, your build target is too old. For Vite, set target: 'es2022' and use @vitejs/plugin-react (esbuild) — @vitejs/plugin-react-swc does not support TC39 Stage 3 decorators regardless of the configured target.

Class decorators

@entity()

Marks a class as a DAB entity — required on every class you want exposed through the data API. Entity settings are inferred from the class name and convention: the entity name is the kebab-case class name, and the source table is its pluralized snake_case form.

export declare function entity(name?: string): <T extends constructor<IEntity>>(
  _target: T,
  _context: ClassDecoratorContext<T>,
) => void;
rayfin/data/Todo.ts
import { entity, uuid, text } from '@microsoft/rayfin-core';

@entity()
export class Todo {
  @uuid() id!: string;
  @text({ max: 200 }) title!: string;
}

Every entity has an id field that serves as its UUID primary key. If you don't declare id explicitly, Rayfin adds it to the schema automatically; the field is always optional on create (the server generates one when omitted). Composite or non-id primary keys are not supported.

@blob()

Marks a class as a blob storage folder configuration, used with @microsoft/rayfin-storage. Permissions and visibility (public vs. private) are inferred from the class's @role() / @authenticated() decorators — a folder is public if the anonymous role has any granted permissions.

export declare function blob(_folderName?: string): <T extends constructor<unknown>>(
  _target: T,
  context: ClassDecoratorContext<T>,
) => void;

The parameter sets the folder name; it defaults to the kebab-case class name.

import { blob, role } from '@microsoft/rayfin-core';

@blob('uploads')
@role('authenticated', '*')
export class FileModel {
  owner_id!: string;
}

Field decorators

Every field on an @entity() class needs exactly one type decorator. All of them accept a shared set of base options, plus type-specific options.

Shared base options

OptionTypeDefaultDescription
optionalbooleanfalseAllows NULL. Fields are required by default — combine with a TypeScript ? on the property.
uniquebooleanfalseAdds a unique constraint.
defaultmatches the field's typenoneDefault value for the column.

@text(options?) / @email(options?)

Variable-length text. @email() accepts the identical option type (TextFieldOptions) and adds an email-format hint used by downstream validation and UI — it does not have its own narrower option set.

OptionTypeDefaultDescription
maxnumberdialect maximumMaximum character length. Omitting it (or passing -1) falls back to the database's maximum, which on MSSQL is NVARCHAR(MAX) — this breaks GraphQL schema generation at deploy time. Always set max explicitly; see Known limitations.
minnumbernoneMinimum character length.
regexRegExpnonePattern the value must match.
import { entity, uuid, text, email } from '@microsoft/rayfin-core';

@entity()
export class Author {
  @uuid() id!: string;
  @text({ max: 200 }) name!: string;
  @text({ optional: true, max: 2000 }) bio?: string;
  @email({ unique: true }) emailAddress!: string;
}

@uuid(options?)

UUID identifier — the conventional type for primary keys and foreign key columns. Accepts only the shared base options (no type-specific ones). A foreign key column that references another entity's @uuid() primary key must itself be @uuid() — a @text() field cannot stand in for it (an auth-derived field like user_id sourced from claims.sub is a @text() field, not a foreign key, so this rule doesn't apply to it).

@uuid() id!: string;
@uuid() category_id!: string; // foreign key — must match the referenced PK type

@int(options?)

Whole numbers.

OptionTypeDefaultDescription
max / minnumbernoneValue bounds.

@decimal(options?)

Fixed-point decimal, for money and other precise numeric values.

OptionTypeDefaultDescription
max / minnumbernoneValue bounds.
precisionnumber18Total digits. Maximum 28 (the Data API Builder runtime limit). Must be specified together with scale.
scalenumber2Digits after the decimal point. Must be between 0 and precision.

With neither option set, @decimal() defaults to DECIMAL(18,2).

@decimal() price!: number;                       // DECIMAL(18,2)
@decimal({ precision: 10, scale: 4 }) weight!: number; // DECIMAL(10,4)

@boolean(options?)

True/false values. Base options only.

@date(options?)

ISO-8601 date/time, serialized from Date objects or ISO strings. Base options only.

@set(...)

String enum with a database check constraint. @set has three call forms, all producing the same field behavior — pick whichever reads best at the call site:

type FieldDecoratorFn<T> = (
  target: unknown,
  context: ClassFieldDecoratorContext<unknown, T | undefined>,
) => void;

// 1. Values only
function set<T extends [string, ...string[]]>(...values: T): FieldDecoratorFn<T[number]>;

// 2. A single options object, with values in `enum`
function set<T extends [string, ...string[]]>(options: SetFieldOptions<T>): FieldDecoratorFn<T[number]>;

// 3. Base options object, followed by values
function set<T extends [string, ...string[]]>(
  options: Omit<SetFieldOptions<T>, 'enum'>,
  ...values: T
): FieldDecoratorFn<T[number]>;

SetFieldOptions<T> adds one required property, enum: T, on top of the shared base options.

// Form 1 — values only
@set('todo', 'in-progress', 'done') status!: 'todo' | 'in-progress' | 'done';

// Form 2 — options object with `enum`
@set({ enum: ['todo', 'in-progress', 'done'], optional: true })
status?: 'todo' | 'in-progress' | 'done';

// Form 3 — base options, then values
@set({ optional: true }, 'todo', 'in-progress', 'done')
status?: 'todo' | 'in-progress' | 'done';

Field type to database mapping

DecoratorLogical typeDatabase mapping
@uuid()UUIDUNIQUEIDENTIFIER
@text()stringNVARCHAR(n), sized from max
@int()integerINT
@decimal()decimalDECIMAL
@boolean()booleanBIT
@date()datetimeDATETIME2
@email()stringSame as @text(), plus an email-format hint
@set()enumString column with a check constraint

Relationship decorators

@one() and @many() take a lazy () => Target function (not the class directly) so entities in different files can reference each other without import-order issues. Both accept only RelationshipFieldOptions — the base options minus default:

OptionTypeDefaultDescription
optionalbooleanfalseNullable relationship (optional foreign key).
uniquebooleanfalseMakes a @one() relationship one-to-one instead of many-to-one.

Custom foreign key or target key names are not supported — see Known limitations.

@one(target, options?)

Many-to-one (or one-to-one with unique: true). Rayfin auto-generates a foreign key column named {property}_id — you don't need to declare it unless your application code reads or writes the raw ID.

import { entity, uuid, text, one } from '@microsoft/rayfin-core';
import { Category } from './Category.js'; // `import`, not `import type` — the decorator needs the runtime class

@entity()
export class Todo {
  @uuid() id!: string;
  @text() title!: string;
  @one(() => Category) category!: Category;   // generates category_id
  @one(() => Category, { optional: true }) parentCategory?: Category;
}

@many(target, options?)

The inverse, one-to-many side of a @one() relationship.

import { entity, uuid, text, many } from '@microsoft/rayfin-core';
import { Todo } from './Todo.js';

@entity()
export class Category {
  @uuid() id!: string;
  @text() name!: string;
  @many(() => Todo) todos?: Todo[];
}

Many-to-many relationships are not supported directly — model them with an explicit join entity that has two @one() fields. See Known limitations.

Note

@microsoft/rayfin-core ships a built-in, read-only User system entity (id, email) that is always a valid @one() / @many() target — you don't need to declare it in your own schema to reference it.

Permission decorators

Every entity needs an explicit permission decorator. Omitting one is not the same as denying access — it silently applies authenticated: * (full CRUD for any signed-in user), which is rarely what you want in production.

@authenticated(actions?, options?)

Shorthand for the built-in authenticated role — requires a valid user session.

type RoleDecoratorFn<TEntity> = (
  target: constructor<TEntity>,
  context: ClassDecoratorContext<constructor<TEntity>>,
) => void;

function authenticated<TEntity extends object = object>(
  actions?: SimpleAction | SimpleAction[], // default: '*'
  options?: RoleDeclarationOptions<TEntity>,
): RoleDecoratorFn<TEntity>;
ParameterTypeDefaultDescription
actions'create' | 'read' | 'update' | 'delete' | 'execute' | '*', or an array of them'*'The DAB action(s) this role may perform. 'execute' applies to stored procedures.
options.policy(claims, item) => PolicyExpressionnoneRow-level filter — see Policy DSL below.
options.include(keyof TEntity)[]noneRestrict the role to only these fields.
options.exclude(keyof TEntity)[]noneHide these fields from the role.
rayfin/data/Todo.ts
import { entity, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@authenticated('*', {
  policy: (claims, item) => claims.sub.eq(item.user_id),
})
export class Todo {
  @uuid() id!: string;
  @text({ max: 200 }) title!: string;
  @text() user_id!: string;
}

@role(roleName, actions, options?)

The general-purpose form that @authenticated() and @anonymous() are shorthands for.

function role<TEntity extends object = object>(
  roleName: 'authenticated' | 'anonymous',
  actions: SimpleAction | SimpleAction[],
  options?: RoleDeclarationOptions<TEntity>,
): RoleDecoratorFn<TEntity>;

@anonymous(actions?, options?)

Public, unauthenticated access. Exported from the package root:

function anonymous<TEntity extends object = object>(
  actions?: SimpleAction | SimpleAction[], // default: '*'
  options?: RoleDeclarationOptions<TEntity>,
): RoleDecoratorFn<TEntity>;
import { entity, uuid, text, anonymous, authenticated } from '@microsoft/rayfin-core';

@entity()
@anonymous('read')
@authenticated('*')
export class PublicPost {
  @uuid() id!: string;
  @text({ max: 200 }) title!: string;
}

Note

Anonymous access on a deployed Fabric app also requires a tenant administrator to enable anonymous data access for the tenant. See Permissions for the full picture.

Warning

Anonymous data access is not currently supported when deployed to Fabric: the CLI rejects any DAB configuration that grants the anonymous role at apply time, regardless of whether you used @anonymous(...) or @role('anonymous', ...). Treat this as a preview of a future stable API, and prefer @anonymous() (rather than the role() widening) so the intent is clear at the call site. To use anonymous data with Microsoft Fabric, contact your tenant admin about the "Enable anonymous data access for Fabric Apps" setting.

Policy DSL and field visibility

The policy callback receives two typed arguments and returns a composable expression that compiles to a DAB policy string:

  • claims — a ClaimsDsl exposing claims.sub, claims.email, and claims.role. These are the only supported claims.
  • item — a proxy with one property per entity field, typed from the class itself so a typo fails at compile time.

Both claims.<name> and item.<field> support .eq(value) and .neq(value), each returning a PolicyExpression. Combine expressions with .and() / .or(), which parenthesize both sides automatically:

policy: (claims, item) =>
  claims.role.eq('admin').or(claims.sub.eq(item.owner_id).and(item.isActive.eq(true)))

Use include / exclude on any role decorator to control field-level visibility per action. Both arrays are typed against the entity's actual property names:

import { entity, authenticated, uuid, text } from '@microsoft/rayfin-core';

@entity()
@authenticated('read', { exclude: ['secret'] })
@authenticated(['create', 'update'], {
  policy: (claims, item) => claims.sub.eq(item.owner_id),
})
export class Document {
  @uuid() id!: string;
  @text() owner_id!: string;
  @text() title!: string;
  @text({ optional: true }) secret?: string;
}

Multiple role decorators on the same class are aggregated; conflicting declarations for the same role and action produce a warning at generation time.

Form validation

@microsoft/rayfin-core can build a Standard Schema validator directly from an @entity() class, so you can validate form input without a separate library.

function toStandardSchema<T, K extends keyof T = never>(
  entity: EntityClass<T>,
  options?: { omit?: readonly K[] },
): RayfinStandardSchema<Omit<T, K | 'id'>>;

function getFieldConstraints<T, K extends keyof T>(
  entity: EntityClass<T>,
  field: K & string,
): FieldConstraints | undefined;

toStandardSchema automatically omits the id primary key and any @one() / @many() relationship properties — pass additional field names (like timestamps) via omit.

import { toStandardSchema, getFieldConstraints } from '@microsoft/rayfin-core';
import { Todo } from '../rayfin/data/Todo.js';

const todoInputSchema = toStandardSchema(Todo, {
  omit: ['createdAt', 'updatedAt'] as const,
});

const result = todoInputSchema.validate({ title: 'Write docs' });
if (result.issues) {
  // result.issues: readonly StandardSchemaV1.Issue[]
} else {
  // result.value is typed as Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>
}

const titleConstraints = getFieldConstraints(Todo, 'title');
// { type: 'string', min: 1, max: 200, optional: false }

The returned object implements the StandardSchemaV1 contract (~standard), so it works directly with TanStack Form, Conform, tRPC v11, and any other Standard Schema–compatible library, in addition to its own convenience .validate() method.

Something wrong on this page?Report an issueEdit this page

On this page