Skip to main content

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 button
  • uiState — 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

model/src/index.ts
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

ui/src/MainPage.vue
<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

model/src/index.ts
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

ui/src/MainPage.vue
<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

AspectV1/V2V3
State storageargs + uiStateSingle data object
Run activationAny args changeDerived args change
Readiness gate.argsValid(fn)Throw in .args(fn)
UI bindingsapp.model.args.*, app.model.ui.*app.model.data.*
Builder entryBlockModel.create()BlockModelV3.create(dataModel)
Export name (convention)modelplatforma

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.

info

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.args is Args | undefined (undefined when the args function throws) — use optional chaining
  • ctx.uiState is gone
  • ctx.data provides 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.