Skip to main content
All Examples

Interrupting Commands

Simulated file uploads driven by interruptible Commands. Cancel one upload, cancel every upload in flight, or restart a cancelled upload through a keyed interrupt registry and an outcome-carrying result Message.

Commands
Concurrency
/
import clsx from 'clsx'
import {
  Array,
  Duration,
  Effect,
  Match as M,
  Number,
  Option,
  Schema as S,
  pipe,
} from 'effect'
import { Command, Runtime, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// MODEL

export const UploadStatus = S.Literals(['Uploading', 'Done', 'Cancelled'])
export type UploadStatus = typeof UploadStatus.Type

export const Upload = S.Struct({
  id: S.Number,
  fileName: S.String,
  sizeMegabytes: S.Number,
  status: UploadStatus,
})
export type Upload = typeof Upload.Type

export const Model = S.Struct({
  uploadId: S.Number,
  uploads: S.Array(Upload),
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  ClickedStartUpload: {},
  ClickedCancelUpload: { uploadId: S.Number },
  ClickedCancelAllUploads: {},
  ClickedRestartUpload: { uploadId: S.Number },
  SucceededUploadFile: { uploadId: S.Number },
  CompletedCancelUploadFile: {
    uploadId: S.Number,
    outcome: Command.Interruptible.Outcome,
  },
})

export type Message = typeof Message.Type

// INIT

export const initialModel: Model = {
  uploadId: 0,
  uploads: [],
}

export const init: Runtime.ApplicationInit<Model, Message> = () => ({
  model: initialModel,
})

// FAKE FILES

export const FakeFile = S.Struct({ name: S.String, sizeMegabytes: S.Number })
export type FakeFile = typeof FakeFile.Type

export const FAKE_FILES: Array.NonEmptyReadonlyArray<FakeFile> = [
  { name: 'vacation-photos.zip', sizeMegabytes: 48 },
  { name: 'demo-recording.mp4', sizeMegabytes: 87 },
  { name: 'quarterly-report.pdf', sizeMegabytes: 12 },
  { name: 'design-assets.sketch', sizeMegabytes: 34 },
  { name: 'database-backup.sql', sizeMegabytes: 61 },
]

const fakeFileForUpload = (uploadId: number): FakeFile =>
  pipe(
    FAKE_FILES,
    Array.get(uploadId % Array.length(FAKE_FILES)),
    Option.getOrElse(() => Array.headNonEmpty(FAKE_FILES)),
  )

// COMMAND

const MILLISECONDS_PER_MEGABYTE = 100

export const UploadKey = S.Struct({ uploadId: S.Number })
export type UploadKey = typeof UploadKey.Type

export const UploadFile = Command.define('UploadFile', {
  args: { ...UploadKey.fields, sizeMegabytes: S.Number },
  messages: [Message.SucceededUploadFile],
  interrupt: {
    keyFields: ['uploadId'],
    toKey: ({ uploadId }) => String(uploadId),
  },
  execute: ({ uploadId, sizeMegabytes }) =>
    Effect.gen(function* () {
      yield* Effect.sleep(
        Duration.millis(sizeMegabytes * MILLISECONDS_PER_MEGABYTE),
      )
      return Message.SucceededUploadFile({ uploadId })
    }),
})

export const CancelUploadFile = ({ uploadId }: UploadKey) =>
  UploadFile.Interrupt({ uploadId }, outcome =>
    Message.CompletedCancelUploadFile({ uploadId, outcome }),
  )

// UPDATE

const setStatusForId = (uploadId: number, status: UploadStatus) =>
  Array.map((upload: Upload) =>
    upload.id === uploadId ? evo(upload, { status: () => status }) : upload,
  )

type UpdateReturn = Update.Return<Model, Message>

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    ClickedStartUpload: () => {
      const fakeFile = fakeFileForUpload(model.uploadId)
      const startedUpload = Upload.make({
        id: model.uploadId,
        fileName: fakeFile.name,
        sizeMegabytes: fakeFile.sizeMegabytes,
        status: 'Uploading',
      })
      return {
        model: evo(model, {
          uploadId: Number.increment,
          uploads: Array.append(startedUpload),
        }),
        commands: [
          UploadFile({
            uploadId: startedUpload.id,
            sizeMegabytes: startedUpload.sizeMegabytes,
          }),
        ],
      }
    },

    ClickedCancelUpload: ({ uploadId }) => ({
      model,
      commands: [CancelUploadFile({ uploadId })],
    }),

    ClickedCancelAllUploads: () => {
      return {
        model,
        commands: pipe(
          model.uploads,
          Array.filter(upload => upload.status === 'Uploading'),
          Array.map(upload => CancelUploadFile({ uploadId: upload.id })),
        ),
      }
    },

    ClickedRestartUpload: ({ uploadId }) =>
      pipe(
        model.uploads,
        Array.findFirst(
          upload => upload.id === uploadId && upload.status === 'Cancelled',
        ),
        Option.match({
          onNone: () => ({ model }),
          onSome: upload => ({
            model: evo(model, {
              uploads: setStatusForId(uploadId, 'Uploading'),
            }),
            commands: [
              UploadFile({ uploadId, sizeMegabytes: upload.sizeMegabytes }),
            ],
          }),
        }),
      ),

    SucceededUploadFile: ({ uploadId }) => ({
      model: evo(model, { uploads: setStatusForId(uploadId, 'Done') }),
    }),

    CompletedCancelUploadFile: ({ uploadId, outcome }) =>
      Command.Interruptible.Outcome.match<UpdateReturn>(outcome, {
        Interrupted: () => ({
          model: evo(model, {
            uploads: setStatusForId(uploadId, 'Cancelled'),
          }),
        }),
        NotFound: () => ({ model }),
      }),
  })

// VIEW

const badgeClass = (status: UploadStatus): string =>
  M.value(status).pipe(
    M.when('Uploading', () => 'bg-blue-100 text-blue-700'),
    M.when('Done', () => 'bg-green-100 text-green-700'),
    M.when('Cancelled', () => 'bg-gray-200 text-gray-600'),
    M.exhaustive,
  )

const ACTION_BUTTON_CLASS =
  'px-3 py-1 text-sm font-medium rounded-md border transition'

const uploadActionView = (upload: Upload, h: HtmlBuilder<Message>): Html =>
  M.value(upload.status).pipe(
    M.when('Uploading', () =>
      h.keyed('button')(
        'Uploading',
        [
          h.OnClick(Message.ClickedCancelUpload({ uploadId: upload.id })),
          h.AriaLabel(`Cancel upload ${upload.id}`),
          h.Class(
            clsx(
              ACTION_BUTTON_CLASS,
              'border-red-300 text-red-600 hover:bg-red-50',
            ),
          ),
        ],
        ['Cancel'],
      ),
    ),
    M.when('Cancelled', () =>
      h.keyed('button')(
        'Cancelled',
        [
          h.OnClick(Message.ClickedRestartUpload({ uploadId: upload.id })),
          h.AriaLabel(`Restart upload ${upload.id}`),
          h.Class(
            clsx(
              ACTION_BUTTON_CLASS,
              'border-blue-300 text-blue-600 hover:bg-blue-50',
            ),
          ),
        ],
        ['Restart'],
      ),
    ),
    M.when('Done', () => h.empty),
    M.exhaustive,
  )

const uploadView = (upload: Upload, h: HtmlBuilder<Message>): Html =>
  h.keyed('li')(
    String(upload.id),
    [h.Class('p-4 bg-white rounded-lg shadow flex flex-col gap-2')],
    [
      h.div(
        [h.Class('flex items-center justify-between gap-3')],
        [
          h.div(
            [h.Class('flex items-baseline gap-2 min-w-0')],
            [
              h.span(
                [h.Class('font-medium text-gray-800 truncate')],
                [upload.fileName],
              ),
              h.span(
                [h.Class('text-sm text-gray-500 shrink-0')],
                [`${upload.sizeMegabytes} MB`],
              ),
            ],
          ),
          h.div(
            [h.Class('flex items-center gap-3 shrink-0')],
            [
              h.span(
                [
                  h.Class(
                    clsx(
                      'px-2 py-0.5 text-xs font-semibold rounded-full',
                      badgeClass(upload.status),
                    ),
                  ),
                ],
                [upload.status],
              ),
              uploadActionView(upload, h),
            ],
          ),
        ],
      ),
      upload.status === 'Uploading'
        ? h.div([h.Class('h-1.5 rounded-full bg-blue-400 animate-pulse')])
        : h.empty,
    ],
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const isAnyUploadRunning = Array.some(
    model.uploads,
    upload => upload.status === 'Uploading',
  )

  const body = h.div(
    [h.Class('min-h-screen bg-gray-100 py-8')],
    [
      h.div(
        [h.Class('max-w-lg mx-auto flex flex-col gap-6 px-4')],
        [
          h.h1(
            [h.Class('text-3xl font-bold text-gray-800 text-center')],
            ['File Uploads'],
          ),
          h.div(
            [h.Class('flex justify-center gap-3')],
            [
              h.button(
                [
                  h.OnClick(Message.ClickedStartUpload()),
                  h.Class(
                    'px-4 py-2 rounded-md bg-blue-500 text-white font-medium hover:bg-blue-600 transition',
                  ),
                ],
                ['Upload a file'],
              ),
              isAnyUploadRunning
                ? h.keyed('button')(
                    'CancelAll',
                    [
                      h.OnClick(Message.ClickedCancelAllUploads()),
                      h.Class(
                        'px-4 py-2 rounded-md border border-red-300 text-red-600 font-medium hover:bg-red-50 transition',
                      ),
                    ],
                    ['Cancel all'],
                  )
                : h.empty,
            ],
          ),
          Array.match(model.uploads, {
            onEmpty: () =>
              h.keyed('p')(
                'NoUploads',
                [h.Class('text-center text-gray-500')],
                ['Nothing here yet. Start an upload.'],
              ),
            onNonEmpty: uploads =>
              h.keyed('ul')(
                'UploadList',
                [h.Class('flex flex-col gap-3')],
                Array.map(uploads, upload => uploadView(upload, h)),
              ),
          }),
        ],
      ),
    ],
  )

  return { title: 'Foldkit Interrupting Commands Example', body }
}