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 };
})

A block now declares a kind

A V3 block carries a block kind — a small sibling kind/ package that declares the typed params a block of that kind can be created from — and both model builders take it. Two consequences for a migration: BlockModelV3.create takes an object, { dataModel, kind }, not a bare data model; and .templateParams(…) is required, so done() throws without it. See Adding a kind to an existing block for the mechanics.

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';
import { kind } from '@platforma-open/milaboratories.clonotype-clustering.kind';

type BlockData = {
datasetRef?: PlRef;
sequenceType: 'aminoacid' | 'nucleotide';
identity: number;
};

const dataModel = new DataModelBuilder({ kind })
.from<BlockData>('v1')
.init(({ params }) => ({
sequenceType: params?.sequenceType ?? 'aminoacid',
identity: params?.identity ?? 0.8,
}));

export const platforma = BlockModelV3.create({ dataModel, kind })
.args((data) => {
if (!data.datasetRef) throw new Error('Dataset is required');
return {
datasetRef: data.datasetRef,
sequenceType: data.sequenceType,
identity: data.identity,
};
})

.templateParams((data) => ({
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, kind })
Params contractDeclared by the block's kind
Params projection.templateParams(fn), required
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. Scaffold the kind and choose its params contract

Run block-tools structure refresh in the block root. It creates the kind/ package, wires it as a dependency of model/ and a devDependency of the block facade, and you add kind to the packages: list in pnpm-workspace.yaml.

The scaffold ships two deliberate sentinels that do not compile: export type BlockParams = NEEDS_BLOCK_PARAMS; and a parseInitializationParams returning {}. Replace the type with the real contract, then make the runtime check read the keys it declares.

info

BlockParams is generally not the same type as BlockData. It is the subset a creator or a project template supplies, so drop view-only state (table grid state, graph state), and make a field optional wherever a half-configured block is a legitimate state the UI can reach.

If the block deliberately takes no params, say so on purpose: Record<string, never>.

The full recipe — the API, the publish mechanics — is on Block kinds.

3. 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({ kind })
.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.

4. 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, kind })
.args((data) => {
if (!data.datasetRef) throw new Error('Dataset is required');
return { datasetRef: data.datasetRef, sequenceType: data.sequenceType, identity: data.identity };
})
.templateParams((data) => ({
datasetRef: data.datasetRef,
sequenceType: data.sequenceType,
identity: data.identity,
}))
.output(...)
.done();

5. 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);
})

6. 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" ... />

7. 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.