Skip to main content

Block model

The block model is the layer between a block's UI and its workflow. It defines the block's state, controls when the workflow can run, computes outputs for the UI to display, and provides metadata like the block's title and navigation sections. All of this is declared in a single builder chain exported from model/src/index.ts.

The two main primitives are DataModelBuilder, which defines the shape of the block's state and how it migrates between versions, and BlockModelV3, which wires that state to the workflow and the UI. Both are imported from @platforma-sdk/model.

Core concepts

A block stores all its state in a single data object — user selections, configuration, UI preferences. Several things are derived from data:

  • args(data) derives the arguments the workflow receives. When the derived args change, the block's Run button becomes active, letting the user start a new execution. Fields in data that are not included in the args return value can change freely without activating the Run button.
  • prerunArgs(data) (optional) derives separate arguments for the staging (pre-run) phase, allowing lightweight preview computations before a full run.
  • Outputs, title, sections, tags are also computed from data and workflow results, providing the UI with display values and block metadata.

Defining the data shape

DataModelBuilder defines the block's data type, assigns it a version, and provides default values for new blocks.

import { BlockModelV3, DataModelBuilder } from '@platforma-sdk/model';
import type { PlRef } from '@platforma-sdk/model';

type BlockData = {
inputRef?: PlRef;
species: string;
chains: string[];
};

const dataModel = new DataModelBuilder()
.from<BlockData>('v1')
.init(() => ({
species: 'human',
chains: ['TRB'],
}));
  • from<T>(version) — sets the data type and a version string
  • init(fn) — factory for default values, used for new blocks and as a fallback when migrations fail

This is enough for a first release. When the data shape needs to change, add migrations.

Data migrations

As the block above evolves, suppose we need to add a preset field. Rather than breaking existing saved blocks, we add a migration step. The original type becomes BlockDataV1, the new type becomes BlockDataV2, and a BlockData alias always points to the latest version:

type BlockDataV1 = {
inputRef?: PlRef;
species: string;
chains: string[];
};

type BlockDataV2 = {
inputRef?: PlRef;
species: string;
chains: string[];
preset: string;
};

type BlockData = BlockDataV2;

const dataModel = new DataModelBuilder()
.from<BlockDataV1>('v1')
.migrate<BlockDataV2>('v2', (v1) => ({ ...v1, preset: 'default' }))
.init(() => ({
species: 'human',
chains: ['TRB'],
preset: 'default',
}));

When a block saved at v1 is opened, the migration runs and produces v2 data. New blocks start from init() directly.

Key points:

  • Version keys are arbitrary strings
  • Each step is typed: the migration function receives the previous version's type and must return the next
  • Migrations chain — a block saved at v1 runs through all subsequent steps to reach the latest version
  • If a migration throws, the block resets to init() defaults with a warning (the original data is preserved until the user commits)

Handling unforeseen versions

In rare cases — a development branch that wrote an unknown version, or data corruption — the block may encounter a version string not in its migration chain. recover() provides a safety net:

const dataModel = new DataModelBuilder()
.from<BlockDataV1>('v1')
.recover((unknownData) => ({
species: unknownData?.species ?? 'human',
chains: Array.isArray(unknownData?.chains) ? unknownData.chains : ['TRB'],
}))
.migrate<BlockDataV2>('v2', (v1) => ({ ...v1, preset: 'default' }))
.init(() => ({ species: 'human', chains: ['TRB'], preset: 'default' }));

recover() receives the raw data and returns a value matching the type at that position in the chain. Here it returns BlockDataV1, and the subsequent migration to v2 still applies. Use it as a defensive measure, not as a primary migration path.

info

For blocks migrating from the V1/V2 block model, use upgradeLegacy(). See the migration guide.

Building the block model

BlockModelV3.create(dataModel) starts a builder chain that connects data to the workflow and UI.

export const platforma = BlockModelV3.create(dataModel)
.args((data) => {
if (!data.inputRef) throw new Error('Input is required');
return {
inputRef: data.inputRef,
species: data.species,
chains: data.chains,
preset: data.preset,
};
})
.output('inputOptions', (ctx) =>
ctx.resultPool.getOptions((spec) => isPColumnSpec(spec) && spec.name === 'pl7.app/vdj/sequence')
)
.sections(() => [{ type: 'link', href: '/', label: 'Main' }])
.title(() => 'My Analysis Block')
.done();

args(fn)

Required. Derives workflow arguments from block data. When the return value changes, the Run button activates.

.args((data) => {
if (!data.inputRef) throw new Error('Input is required');
return {
inputRef: data.inputRef,
species: data.species,
chains: data.chains,
preset: data.preset,
};
})

To signal that the block isn't ready to run, throw an error or return undefined — both disable the Run button. Throwing with a descriptive message is preferred — the message will be surfaced to the user in a future SDK version.

Fields excluded from the return value (like a graph scale preference) can change freely in data without activating the Run button.

prerunArgs(fn)

Optional. Defines separate arguments for the staging (pre-run) phase. If not defined, falls back to args().

.prerunArgs((data) => ({
species: data.species,
chains: data.chains,
}))

Pre-run results are available through ctx.prerun in output functions. If prerunArgs() throws, the block is skipped during staging.

output(key, fn)

Defines named values computed from the block's render context, accessible in the UI as app.model.outputs.<key>.

.output('inputOptions', (ctx) =>
ctx.resultPool.getOptions((spec) => isPColumnSpec(spec) && spec.name === 'pl7.app/vdj/sequence')
)
.output('resultTable', (ctx) => {
const pCols = ctx.outputs?.resolve('results')?.getPColumns();
if (!pCols) return undefined;
return createPFrameForGraphs(ctx, pCols);
})

The render context provides:

PropertyTypeDescription
ctx.dataDataCurrent block data
ctx.argsArgs | undefinedDerived args; undefined when block is not ready
ctx.activeArgsArgs | undefinedArgs from the currently running or last completed execution
ctx.outputsResultPool | undefinedWorkflow outputs from the main execution
ctx.prerunResultPool | undefinedWorkflow outputs from the pre-run phase
ctx.resultPoolResultPoolFull result pool for querying upstream blocks

ctx.args reflects the current derivation — it's undefined when the args function throws or returns undefined. ctx.activeArgs reflects what the workflow is actually running with (or last ran with), which may differ from ctx.args if the user has changed data since the last run.

sections, title, subtitle, tags

Configure block appearance in the Platforma UI. All accept a function receiving the render context.

.sections((ctx) => [
{ type: 'link', href: '/', label: 'Main', badge: ctx.args?.badgeText },
{ type: 'link', href: '/results', label: 'Results' },
])
.title(() => 'Clonotype Clustering')
.subtitle((ctx) => ctx.args?.customLabel || 'Default label')
.tags((ctx) => ['analysis', ...(ctx.args?.tagList || [])])

Since ctx.args can be undefined, use optional chaining when accessing args properties.

done()

Closes the builder and returns the finalized model. Export it for use in the UI layer.

export const platforma = BlockModelV3.create(dataModel)
.args(...)
.done();

export type BlockOutputs = InferOutputsType<typeof platforma>;
export type Href = InferHrefType<typeof platforma>;

UI integration

App setup

ui/src/app.ts
import { defineApp } from '@platforma-sdk/ui-vue';
import { platforma } from '@myorg/my-block.model';
import MainPage from './MainPage.vue';

export const sdkPlugin = defineApp(platforma, () => ({
routes: {
'/': () => MainPage,
},
}));

export const useApp = sdkPlugin.useApp;

Using data and outputs in components

ui/src/MainPage.vue
<script setup lang="ts">
import { useApp } from './app';
const app = useApp();
</script>

<template>
<PlBlockPage>
<PlTextField v-model="app.model.data.species" label="Species" />
<PlDropdownRef
v-model="app.model.data.inputRef"
:options="app.model.outputs.inputOptions"
label="Input"
/>
<pre>{{ app.model.outputs.resultTable }}</pre>
</PlBlockPage>
</template>
  • app.model.data — all block state. Directly mutable and reactive; assigning a field triggers debounced persistence and args re-derivation.
  • app.model.outputs — computed values from the model's .output() definitions.