Migrating to block model V3
This guide is for developers with existing blocks built on the V1/V2 block model (BlockModel.create(), .withArgs(), .withUiState()). It covers what changed and how to migrate.
What changed
The args/uiState split is gone
In V1/V2, block state was split into two buckets:
args— sent to the workflow; any change activates the Run buttonuiState— stays in the UI, never affects the workflow
This forced developers to decide where each piece of state lives at design time. V3 replaces both with a single data object. A pure function args(data) derives workflow arguments from data. The Run button activates only when the derived args change — not when data changes.
Before (V1/V2): "Where does this state go — args or uiState?" After (V3): "Does this state affect the workflow?" — answered by what you include in the args function.
Why this matters in practice
Parallel arrays that must stay in sync. A diversity block needs a list of metrics with UI annotations (id for drag-and-drop, isExpanded for accordions). In V1/V2, the UI annotations go in uiState while the metric parameters go in args, creating two parallel arrays. In V3, it's one list — the args function simply ignores the UI fields.
False Run button triggers. The Run button appears whenever args change, so developers had to carefully split state to avoid a graph toggle prompting to re-run a multi-hour computation.
argsValid is replaced by throwing or returning undefined
V1/V2 used .argsValid(fn) to gate execution:
// V1/V2
.argsValid((ctx) => ctx.args.datasetRef !== undefined && ctx.args.sequencesRef.length > 0)
In V3, this is handled directly in the args() function — throw or return undefined when the block isn't ready:
// V3
.args((data) => {
if (!data.datasetRef) throw new Error('Dataset is required');
if (data.sequencesRef.length === 0) throw new Error('Select at least one sequence');
return { datasetRef: data.datasetRef, sequencesRef: data.sequencesRef };
})
Before and after
A simplified block showing the structural changes. The V1/V2 version uses a clonotype clustering block as a basis.
V1/V2 model
import { BlockModel, createPlDataTableStateV2 } from '@platforma-sdk/model';
import type { GraphMakerState, PlDataTableStateV2, PlRef } from '@platforma-sdk/model';
export type BlockArgs = {
datasetRef?: PlRef;
sequenceType: 'aminoacid' | 'nucleotide';
identity: number;
};
export type UiState = {
tableState: PlDataTableStateV2;
graphState: GraphMakerState;
};
export const model = BlockModel.create()
.withArgs<BlockArgs>({
sequenceType: 'aminoacid',
identity: 0.8,
})
.withUiState<UiState>({
tableState: createPlDataTableStateV2(),
graphState: {
title: 'Cluster sizes',
template: 'bins',
currentTab: null,
},
})
.argsValid((ctx) => ctx.args.datasetRef !== undefined)
.output('datasetOptions', (ctx) =>
ctx.resultPool.getOptions(/* ... */)
)
.output('clustersTable', (ctx) => {
const pCols = ctx.outputs?.resolve('clusters')?.getPColumns();
if (!pCols) return undefined;
return createPlDataTableV2(ctx, pCols, ctx.uiState.tableState);
})
.title(() => 'Clonotype Clustering')
.done();
V1/V2 UI
<script setup lang="ts">
import { useApp } from './app';
const app = useApp();
</script>
<template>
<PlDropdownRef
v-model="app.model.args.datasetRef"
:options="app.model.outputs.datasetOptions"
label="Dataset"
/>
<PlNumberField v-model="app.model.args.identity" label="Identity" />
<PlAgDataTable v-model="app.model.ui.tableState" :model="app.model.outputs.clustersTable" />
<GraphMaker v-model="app.model.ui.graphState" :p-frame="app.model.outputs.graphPf" />
</template>
V3 model
import { BlockModelV3, DataModelBuilder } from '@platforma-sdk/model';
import type { PlRef } from '@platforma-sdk/model';
type BlockData = {
datasetRef?: PlRef;
sequenceType: 'aminoacid' | 'nucleotide';
identity: number;
};
const dataModel = new DataModelBuilder()
.from<BlockData>('v1')
.init(() => ({
sequenceType: 'aminoacid',
identity: 0.8,
}));
export const platforma = BlockModelV3.create(dataModel)
.args((data) => {
if (!data.datasetRef) throw new Error('Dataset is required');
return {
datasetRef: data.datasetRef,
sequenceType: data.sequenceType,
identity: data.identity,
};
})
.output('datasetOptions', (ctx) =>
ctx.resultPool.getOptions(/* ... */)
)
.title(() => 'Clonotype Clustering')
.done();
V3 UI
<script setup lang="ts">
import { useApp } from './app';
const app = useApp();
</script>
<template>
<PlDropdownRef
v-model="app.model.data.datasetRef"
:options="app.model.outputs.datasetOptions"
label="Dataset"
/>
<PlNumberField v-model="app.model.data.identity" label="Identity" />
</template>
Key differences
| Aspect | V1/V2 | V3 |
|---|---|---|
| State storage | args + uiState | Single data object |
| Run activation | Any args change | Derived args change |
| Readiness gate | .argsValid(fn) | Throw in .args(fn) |
| UI bindings | app.model.args.*, app.model.ui.* | app.model.data.* |
| Builder entry | BlockModel.create() | BlockModelV3.create(dataModel) |
| Export name (convention) | model | platforma |
Step-by-step migration
1. Define the data type
Merge your BlockArgs and UiState types into a single BlockData type. Include fields from both that represent meaningful block state. Drop component bookkeeping like tableState and graphState — these will be handled by plugins when available.
type BlockData = {
datasetRef?: PlRef;
sequenceType: 'aminoacid' | 'nucleotide';
identity: number;
settingsOpen: boolean; // UI preference, excluded from args
};
2. Create the data model with upgradeLegacy
Use upgradeLegacy() so existing saved blocks migrate their state:
type OldArgs = { datasetRef?: PlRef; sequenceType: string; identity: number };
type OldUiState = { tableState: PlDataTableStateV2; graphState: GraphMakerState };
const dataModel = new DataModelBuilder()
.from<BlockData>('v1')
.upgradeLegacy<OldArgs, OldUiState>(({ args, uiState }) => ({
datasetRef: args.datasetRef,
sequenceType: args.sequenceType as BlockData['sequenceType'],
identity: args.identity,
settingsOpen: false,
}))
.init(() => ({
sequenceType: 'aminoacid',
identity: 0.8,
settingsOpen: true,
}));
upgradeLegacy() gives you typed access to the old args and uiState. It runs once when a V1/V2 block is opened with the new code.
recover() and upgradeLegacy() are mutually exclusive. recover() handles unknown data versions within a V3 migration chain. upgradeLegacy() handles the one-time upgrade from V1/V2 to V3.
3. Replace BlockModel with BlockModelV3
// Before
export const model = BlockModel.create()
.withArgs<BlockArgs>({...})
.withUiState<UiState>({...})
.argsValid((ctx) => ctx.args.datasetRef !== undefined)
.output(...)
.done();
// After
export const platforma = BlockModelV3.create(dataModel)
.args((data) => {
if (!data.datasetRef) throw new Error('Dataset is required');
return { datasetRef: data.datasetRef, sequenceType: data.sequenceType, identity: data.identity };
})
.output(...)
.done();
4. Update output functions
Output functions now receive a different render context:
ctx.argsisArgs | undefined(undefined when the args function throws) — use optional chainingctx.uiStateis gonectx.dataprovides access to the full block data
// Before
.output('table', (ctx) => {
const pCols = ctx.outputs?.resolve('data')?.getPColumns();
if (!pCols) return undefined;
return createPlDataTableV2(ctx, pCols, ctx.uiState.tableState);
})
// After
.output('tablePf', (ctx) => {
const pCols = ctx.outputs?.resolve('data')?.getPColumns();
if (!pCols) return undefined;
return createPFrameForGraphs(ctx, pCols);
})
5. Update UI bindings
Replace app.model.args.* and app.model.ui.* with app.model.data.*:
<!-- Before -->
<PlDropdownRef v-model="app.model.args.datasetRef" ... />
<PlNumberField v-model="app.model.args.identity" ... />
<!-- After -->
<PlDropdownRef v-model="app.model.data.datasetRef" ... />
<PlNumberField v-model="app.model.data.identity" ... />
6. Update defineApp
// Before
import { model } from '@myorg/my-block.model';
export const sdkPlugin = defineApp(model, (app) => { ... });
// After
import { platforma } from '@myorg/my-block.model';
export const sdkPlugin = defineApp(platforma, () => ({ routes: { '/': () => MainPage } }));
Component state (tables, graphs)
In V1/V2, blocks manually managed state for UI components like data tables and graphs in uiState. V3 introduces a plugin system that lets components own their state. When SDK components ship as plugins, GraphMakerState and PlDataTableStateV2 fields can be removed from your block's data type entirely.