Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] Production ready NextJS integration that scales #97

Open
milanbgd011 opened this issue Jan 26, 2022 · 17 comments
Open
Labels
enhancement New feature or request

Comments

@milanbgd011
Copy link

milanbgd011 commented Jan 26, 2022

Is your feature request related to a problem? Please describe.

First, to clarify. All examples in this repo are perfect for what they were created to demonstrate, that is most minimalistic implementation of specific feature and is very high value for us all. I am just proposing that we add one more advanced example that will make easier for developers to jump into Temporal fully configured and just start using it. Many developers just give up because setting up stuff for NextJS/Temporal is not within reach of all devs out there.

Figuring out state machines is hard. Figuring out that Temporal workflow instance is a "living statemachine" inside Temporal server is hard. Figuring out Temporal is hard. Figuring out how to integrate Temporal into NextJS properly is hard. Then figuring out how to compile TS to JS so that independent worker can use it is another step to do. Your brain will melt during your voyage thru the matter.

Describe the solution you'd like

Add Temporal integration based on existing "One Click Buy" but one that allows user to easily add more workflows and queues without brain damage during the process.

Additional context

Steps to create tight integration with NextJS that scales:

  1. use this example as starting point https://github.com/temporalio/samples-typescript/tree/main/nextjs-ecommerce-oneclick (then just rewire 2 api calls to use new folder structure proposed below, and all should worK)

  2. look at screenshot to get better understanding of folder structure that is being proposed. all files (almost) live in ./workflows directory only inside nextjs app

Screenshot 2022-01-26 at 02 36 50

  1. read explanation why this folder structure is choosen, then get source code for each file below
  • ./workflows/ - all files for Temporal are only here (except single dynamic API endpoint that maps to actions)
    • ./tsconfig.json - typescript compiler config that will be used to convert .ts to .js as source code for worker instance
    • ./__worker_source__ - here we compile .ts to .js and this is folder that will worker directly use to start itselft, pure .js files
    • ./queue1/ is sample naming for first queue, inside it we have 3 files that will be directly consumed by worker instance
      • ./activities - exports all activities for worker to import
      • ./workflows - exports all workflows for worker to import
      • ./worker - actual code that represents worker instance (the file that will be run as worker)
      • ./oneClickBuy/ - folders are only used for defining workflows that contain this file structure
        • ./_workflow-1.ts - first version of workflow definition
        • ./_workflow-2.ts - second revision of workflow definition
        • ./_workflow-current.ts - current revision of workflow definition (we are creating revisions of the same workflow during time, we need to keep all previous versions of workflow definitions that still have at least 1 instance running inside temporal server, so that we can compare old and new workflow definition and make change in latest current so that older workflow instances can work. if no instances of previous revisions exists, they are deleted or after implementation of new)
        • ./CANCEL_act.ts - code that will worker execute against external internet resources if needed
        • ./CANCEL_api.ts - processes request that we got from /api/workflows/queue1/oneClickBuy/CANCEL request
        • ./GETSTATE_api.ts - processes request that we got from /api/workflows/queue1/oneClickBuy/GETSTATE request
        • ./START_act.ts - code that will worker execute against external internet resources if needed
        • ./START_api.ts - processes request that we got from /api/workflows/queue1/oneClickBuy/START request
  1. find out about reasons behind choosing names for files

Anyone familiar with state machines (ie. Xstate) and used it in production systems both front and backend or has used Redux as concept to simplify state management, will be able to recognize pattern here.

With UPPERCASE letters we write "events" that interact with our workflow instance. The workflow instance template is in _workflow-current.ts file and START_api.ts will be responsible to create instance of that workflow in temporal server.

Now reasoning about the code is much much easier. We define workflow and we define events that interact with the instance of workflow that lives in Temporal server, very easy. Each interaction with any queue / workflow / event is as easy as triggering /api/workflows/queue1/oneClickBuy/START and that is it, it can be consumed from nextjs app or from external system.

  1. look at the list of commands to execute after all is set then move to next steps
  • docker-compose -f ./docker-compose-temporal.yml up -d to start temporal server on local machine
  • npm run temporaldev - to start typescript compiler that will read ./workflows and create .js
  • npm run temporalworkerqueue1 - to start instance of worker
  • npm run dev - to run nextjs dev server
  • then goto localhost:3000 and goto page to click on products to order
  • when you click to buy item, then goto http://localhost:8088 Temporal web interface and check if all works
  1. copy paste code into nextjs app

./package.json in scripts segment

    "temporaldev": "tsc --build --watch ./workflows/tsconfig.json",
    "temporalbuild": "tsc --build ./workflows/tsconfig.json",
    "temporalworkerqueue1": "node ./workflows/__worker_source__/queue1/worker.js",

/pages/api/workflows/[[...slug]].ts

// file with this contents, which will be universal endpoint for all actions that we will define in /workflows directory later.
import { NextApiRequest, NextApiResponse } from 'next';

// This dynamic api endpoint will use path provided to hit specific queue/workflow/activity file
// ie. https://SITENAME.com/api/workflows/queue1/oneClickBuy/CANCEL

export default async (req: NextApiRequest, res: NextApiResponse): Promise<any> => {
  const { slug } = req.query || {};
  const [queue, workflow, activity] = slug ? (Array.isArray(slug) ? slug : [slug]) : [];
  const DynamicAPILoader = await import(`@/workflows/${queue}/${workflow}/${activity}_api.ts`);
  if (DynamicAPILoader.default) {
    const [ERRreturnJSON, returnJSON] = await DynamicAPILoader.default({ req, res })
      .then((r: any) => [null, r])
      .catch((e: any) => [e]);
    if (ERRreturnJSON) {
      return res.status(500).json({ errors: [{ error: ERRreturnJSON }] });
    }
    return res.status(200).json(returnJSON);
  }
  return res.status(500).json({ error: 'No API endpoint!' });
};

./workflows/tsconfig.json

{
  "extends": "@tsconfig/node16/tsconfig.json",
  "version": "4.4.2",
  "compilerOptions": {
    "declaration": false,
    "declarationMap": false,
    "sourceMap": true,
    "rootDir": "./",
    "outDir": "./__worker_source__",
    "noImplicitAny": false
  },
  "include": ["**/*.ts"]
}

./workflows/workflows.ts

import { oneClickBuy } from './oneClickBuy/_workflow-current';

export { oneClickBuy };

./workflows/activities.ts

import { oneClickBuy_CANCEL_act } from './oneClickBuy/CANCEL_act';
import { oneClickBuy_START_act } from './oneClickBuy/START_act';

export {
  oneClickBuy_CANCEL_act,
  oneClickBuy_START_act,
};

./workflows/worker.ts

import { Worker } from '@temporalio/worker'; //eslint-disable-line
import * as workerActivities from './activities';

async function run() {
  const worker = await Worker.create({
    taskQueue: `queue1`,
    workflowsPath: require.resolve('./workflows'),
    activities: workerActivities,
  });
  await worker.run();
}
run().catch((err) => {});

./workflows/queue1/_workflow-current.ts

// DEFAULT WORKFLOW DISABLE ESLINT RULES
/* eslint-disable no-void */
/* eslint-disable no-return-assign */
/* eslint-disable no-return-await */
// eslint-disable-next-line
import * as wf from '@temporalio/workflow';

// ACTIVITIES
import type * as activities from '../activities';
// activity configuratino and exporting variables
const { oneClickBuy_CANCEL_act, oneClickBuy_START_act } = wf.proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

// STATES
type PurchaseState = 'PURCHASE_PENDING' | 'PURCHASE_CONFIRMED' | 'PURCHASE_CANCELED';

// SIGNALS
export const cancelPurchase = wf.defineSignal('cancelPurchase');
export const purchaseStateQuery = wf.defineQuery<PurchaseState>('purchaseState');

// WORKFLOW
export async function oneClickBuy(props) {
  const { itemId } = props || {};
  const itemToBuy = itemId;
  let purchaseState: PurchaseState = 'PURCHASE_PENDING';

  // ADD HANDLERS < called with: await workflow.signal('cancelPurchase');
  wf.setHandler(cancelPurchase, () => void (purchaseState = 'PURCHASE_CANCELED'));
  wf.setHandler(purchaseStateQuery, () => purchaseState);

  // WORKFLOW CODE
  if (await wf.condition(() => purchaseState === 'PURCHASE_CANCELED', '5s')) {
    return await oneClickBuy_CANCEL_act(itemToBuy);
  }
  purchaseState = 'PURCHASE_CONFIRMED';
  return await oneClickBuy_START_act(itemToBuy);
}

./workflows/queue1/CANCEL_act.ts

export async function oneClickBuy_CANCEL_act(itemId: string): Promise<string> {
  return `canceled purchase ${itemId}!`;
}

./workflows/queue1/CANCEL_api.ts

// eslint-disable-next-line
import { Connection, WorkflowClient } from '@temporalio/client';

export default async function oneClickBuy_CANCEL_api({ req, res }) {
  const { id } = req?.query || {};
  if (!id) {
    res.status(405).send({ message: 'must send workflow id to cancel' });
    return;
  }

  try {
    const connection = new Connection({ address: process?.env?.TEMPORAL_SERVER || 'http://localhost:7233' });
    const client = new WorkflowClient(connection.service, { namespace: 'default' });
    const workflow = client.getHandle(id);
    await workflow.signal('cancelPurchase');

    res.status(200).json({ cancelled: id });
  } catch (e: any) {
    res.status(500).send({ message: e?.details, errorCode: e?.code });
  }
}

./workflows/queue1/GETSTATE_api.ts

// eslint-disable-next-line
import { Connection, WorkflowClient } from '@temporalio/client';

export default async function oneClickBuy_GETSTATE_api({ req, res }) {
  const { id } = req?.query || {};
  // console.log({ id });
  if (!id) {
    res.status(405).send({ message: 'must send workflow id to query' });
    return;
  }

  try {
    const connection = new Connection({ address: process?.env?.TEMPORAL_SERVER || 'http://localhost:7233' });
    const client = new WorkflowClient(connection.service, { namespace: 'default' });
    const workflow = client.getHandle(id);
    const purchaseState = await workflow.query('purchaseState');

    res.status(200).json({ purchaseState });
  } catch (e: any) {
    res.status(500).send({ message: e?.details, errorCode: e?.code });
  }
}

./workflows/queue1/CANCEL_act.ts

export async function oneClickBuy_START_act(itemId: string): Promise<string> {
  return `checking out ${itemId}!`;
}

./workflows/queue1/CANCEL_act.ts

// eslint-disable-next-line
import { Connection, WorkflowClient } from '@temporalio/client';
import { oneClickBuy as wtf } from './_workflow-current';

export default async function oneClickBuy_START_api({ req, res }) {
  if (req.method !== 'POST') {
    res.status(405).send({ message: 'Only POST requests allowed' });
    return;
  }
  const { itemId, transactionId } = req.body;
  if (!itemId) {
    res.status(405).send({ message: 'must send itemId to buy' });
    return;
  }

  const connection = new Connection({ address: 'localhost:7233' });
  const client = new WorkflowClient(connection.service, {
    namespace: 'default',
  });
  await client
    .start(wtf, {
      taskQueue: 'queue1',
      workflowId: transactionId,
      args: [{ itemId }], // only add params inside object, first arr element
    })
    .then((r) => {
      // console.log({ r });
    })
    .catch((e) => {
      // REPORT TO SENTRY HERE
      // console.log({ e });
    });

  res.status(200).json({ ok: true });
}

ADDITIONAL FILES NEEDED

./docker-compose-temporal.yml

#
#
#    docker-compose -f ./docker-compose-temporal.yml up -d
#    docker-compose -f ./docker-compose-temporal.yml down
#
#
version: "3.5"
services:
  postgresql:
    container_name: temporal-postgresql
    environment:
      POSTGRES_PASSWORD: temporal
      POSTGRES_USER: temporal
    image: postgres:13
    networks:
      - temporal-network
    ports:
      - 5432:5432
  temporal:
    container_name: temporal
    depends_on:
      - postgresql
      - elasticsearch
    environment:
      - DB=postgresql
      - DB_PORT=5432
      - POSTGRES_USER=temporal
      - POSTGRES_PWD=temporal
      - POSTGRES_SEEDS=postgresql
      - DYNAMIC_CONFIG_FILE_PATH=config/.docker-compose/temporal-dev-es.yaml
      - ENABLE_ES=true
      - ES_SEEDS=elasticsearch
      - ES_VERSION=v7
    image: temporalio/auto-setup:1.14.0
    networks:
      - temporal-network
    ports:
      - 7233:7233
    volumes:
      - ./.docker-compose:/etc/temporal/config/.docker-compose
  temporal-admin-tools:
    container_name: temporal-admin-tools
    depends_on:
      - temporal
    environment:
      - TEMPORAL_CLI_ADDRESS=temporal:7233
    image: temporalio/admin-tools:1.14.0
    networks:
      - temporal-network
    stdin_open: true
    tty: true
  temporal-web:
    container_name: temporal-web
    depends_on:
      - temporal
    environment:
      - TEMPORAL_GRPC_ENDPOINT=temporal:7233
      - TEMPORAL_PERMIT_WRITE_API=true
    image: temporalio/web:1.13.0
    networks:
      - temporal-network
    ports:
      - 8088:8088
  elasticsearch:
    container_name: temporal-elasticsearch
    environment:
      - cluster.routing.allocation.disk.threshold_enabled=true
      - cluster.routing.allocation.disk.watermark.low=512mb
      - cluster.routing.allocation.disk.watermark.high=256mb
      - cluster.routing.allocation.disk.watermark.flood_stage=128mb
      - discovery.type=single-node
      - ES_JAVA_OPTS=-Xms100m -Xmx100m
    image: elasticsearch:7.10.1
    networks:
      - temporal-network
    ports:
      - 9200:9200
networks:
  temporal-network:
    driver: bridge
    name: temporal-network

./.docker-compose/temporal-dev-es.yaml

frontend.enableClientVersionCheck:
- value: true
  constraints: {}
history.persistenceMaxQPS:
- value: 3000
  constraints: {}
frontend.persistenceMaxQPS:
- value: 3000
  constraints: {}
frontend.historyMgrNumConns:
- value: 10
  constraints: {}
frontend.throttledLogRPS:
- value: 20
  constraints: {}
history.historyMgrNumConns:
- value: 50
  constraints: {}
history.defaultActivityRetryPolicy:
- value:
    InitialIntervalInSeconds: 1
    MaximumIntervalCoefficient: 100.0
    BackoffCoefficient: 2.0
    MaximumAttempts: 0
history.defaultWorkflowRetryPolicy:
- value:
    InitialIntervalInSeconds: 1
    MaximumIntervalCoefficient: 100.0
    BackoffCoefficient: 2.0
    MaximumAttempts: 0
system.advancedVisibilityWritingMode:
  - value: "on"
    constraints: {}
system.enableReadVisibilityFromES:
  - value: true
    constraints: {}

./docker-compose/temporal-dev.yaml

frontend.enableClientVersionCheck:
- value: true
  constraints: {}
history.persistenceMaxQPS:
- value: 3000
  constraints: {}
frontend.persistenceMaxQPS:
- value: 3000
  constraints: {}
frontend.historyMgrNumConns:
- value: 10
  constraints: {}
frontend.throttledLogRPS:
- value: 20
  constraints: {}
history.historyMgrNumConns:
- value: 50
  constraints: {}
history.defaultActivityRetryPolicy:
- value:
    InitialIntervalInSeconds: 1
    MaximumIntervalCoefficient: 100.0
    BackoffCoefficient: 2.0
    MaximumAttempts: 0
history.defaultWorkflowRetryPolicy:
- value:
    InitialIntervalInSeconds: 1
    MaximumIntervalCoefficient: 100.0
    BackoffCoefficient: 2.0
    MaximumAttempts: 0
system.advancedVisibilityWritingMode:
  - value: "off"
    constraints: {}
@milanbgd011 milanbgd011 added the enhancement New feature or request label Jan 26, 2022
@milanbgd011
Copy link
Author

Let me make this easy for maintainers..

@swyxio swyxio reopened this Feb 3, 2022
@swyxio
Copy link
Contributor

swyxio commented Feb 3, 2022

first of all @milanbgd011 sorry for the delay in getting back to you, I’ve been off for holidays. I really like the suggestions! it is SUPER appreciated.

but there are some design choices here which probably warrant deeper discussion, and theres the fact that it will be hard to maintain over time. i think if you’re interested in setting it up as a repo in your own account we’d be happy to link to you and do a one-off review. thoughts?

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 4, 2022

Thank you @sw-yx for the reply. Did not want to overload you with more work, that's all. Lets dive into the problem thru this issue, then I will create the repo demonstrating the solution. We just need to agree on basic rules that make sense to both you and me first. I did another iteration on the initial suggestion, changed structure is below.

If we want to make Temporal household name for what it does and does it well, we first need to approach the developers in the best possible way so that they can quickly understand the system, easily integrate into their own projects and start playing around with it to be familiar. Then, they will start pushing their product managers to make the switch once they are familiar and confident in the product. Once those companies start to reach bigger workloads they will come to you for support and this is where Temporal will make money (plus the Temporal Cloud soon). Samples provided could be named "simplified architecture for smaller projects" ("use this repo to create clear mental model of Temporal, then organize file structure as you wish"), so devs are aware this is not good scenario for large projects - but just a shortcut to get all up and running allowing devs to be familiar with the techology. 95% of devs drop Temporal because it needs too much time to figure all the details and create clear and easy mental model in their head - so they reach for some simpler but much less powerfull solutions just to do the work and continue with their lives (sometimes they just do not have time available to tackle this with so much time).

Since you are first principles based company, we can analyse this all thru Elon Musks's 5 stages of improvement:

  1. Make Your Requirements Less Dumb.
    • minimize number of steps needed to add temporal into all major frameworks (NextJS,SvelteKit,Nuxt,...)
    • offer docker-compose for local dev all wired up automatically (already there)
    • give user clear instructions how to deploy minimum production ready server in production (for smaller projects)
    • make it easy to run worker both in dev and production env for smaller projects
  2. Try and delete parts of the process.
    • reduce number of both files and folders needed to setup to absolute minimum
    • group files into single root directory named workflows (it sorts it to the bottom of the folder list in editor)
    • instead of making new API route for each action (communication with workflow instance), make just one universal
  3. Simplify or Optimize.
    • use uppercase to express event names (like in Redux) and make it simple to see events in code and in files/folders
    • make it extremely readable inside workflows root directory (using the sample nextjs implementation):
      • /workflows/
        • README.md - full instructions how to create new queues, workflows, events, activities (only minimum stuff needed for it to work and copy/paste to create new ones or append to current ones)
        • /queue0/
          • worker.ts <---- inside we have gathered all activities and workflows via imports and then feed the worker, single file that can be executed directly by ts-node worker.ts and it must work immediately, straightforward
          • /oneClickBuy/ <---- clear and unique name for workflow as directory name
            • /workflow/ <---- store all previous versions (1 is the oldest) and current one to maintain running instances
              • workflow-1.ts <---- first version of workflow, not in use but there to allow dev to rewire "current" properly
              • workflow-2.ts <---- second version (each version adds +1)
              • workflow-current.ts <---- latest version
            • START.ts <---- we hit this via /api/workflows/queue0/onceClickBuy/START extremely easy, just by defining a file in nextjs in pages/api/workflows/[[...slug]].ts with predefined code in there to dynamically load needed event and fire
            • START-activity.ts <---- function that will be fired as "effect" of START event and report to workflow instance
            • CANCEL.ts <---- same logic as START
            • CANCEL-activity.ts <---- same logic as START-activity
            • GETSTATE.ts <---- this event does not have -activity file, which will be very easy to see looking at the files
      • /pages/oneclickbuy.ts <---- frontend that will consume queue0/onClickBuy workflow
      • /pages/api/workflows/[[...slug]].ts <---- route that will forward all events to proper file for dynamic import and exec
  4. Accelerate cycle time.
    • provide simple copy/paste procedures how to add new: queue, workflow, event, activity inside single README.md file
  5. Automate.
    • provide with single bash script that will download and replace /workflows/README.md with the latest version of how-to procedures feed latest and greatest step-by-step directly into the codebase
    • provide bash script that will update the "one click buy" example (copy frontend into /pages/oneclickbuy.ts and overwrite the workflows/queue0 fully to make it work and be updateable over time
  • @sw-yx suggest changes and approve final solution
  • @milanbgd011 create repo based on feedback to test the solution (need to be fully working just by copying into blank nextjs installation and that is all)

@swyxio
Copy link
Contributor

swyxio commented Feb 6, 2022

reduce number of both files and folders needed to setup to absolute minimum

small number of files/folders yes, but we are not extreme about it, we often offer files for code quality, eg eslintrc.js

instead of making new API route for each action (communication with workflow instance), make just one universal

some users may not want this, but sounds ok for this sample!

/queue0/

what is queue0? why is it here in the filepath? you didnt explain why it is necessary to expose queues in the api route

/queue0/worker.ts

i dont think nextjs would support workers running as API routes.. but maybe this is not a problem, need ot see your implementation to understand what you are trying to do

START/CANCEL/GETSTATE

can we combine all these into one file? why so many different files, i thought you wanted absolute minimum

Accelerate cycle time. provide simple copy/paste procedures how to add new: queue, workflow, event, activity inside single README.md file

this sounds like a GREAT idea

provide with single bash script that will download and replace /workflows/README.md with the latest version of how-to procedures feed latest and greatest step-by-step directly into the codebase

no this is not fitting the vision of the samples, we expect our users to modify the examples heavily and so investing in tooling for them to redownload our README makes no sense

for step 5 Automate instead of doing crude overwrite, we should do the "Create React App" approach and encapsulate this in a dependency that we maintain. anyway, we dont have to get to step 5 yet.

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 6, 2022

Hardest part in programming is creating clear mental model in your head how something works.
Then when you understand the system, then you start to alter it to your needs and push the system to its limits.
When you exhaust all the options but need more from service, you contact the creator for help.
Focus here is on the part "create clear mental model" what Temporal is and exactly how it works.
I think that typescript-sdk is extremely clean and minimal.

reduce number of both files and folders needed to setup to absolute minimum

small number of files/folders yes, but we are not extreme about it, we often offer files for code quality, eg eslintrc.js

Implementation should be based on clean NextJS install with npx create-next-app@latest --ts and then just copy sample workflow directory and that's it, but that directory will contain README. Inside README of the workflow directory can be suggested what to add where manually, all in a single place. Folder can be added to current nextjs-ecommerce-oneclick, and named maybe nextjs-ecommerce-oneclick-simplified.

instead of making new API route for each action (communication with workflow instance), make just one universal

some users may not want this, but sounds ok for this sample!

Goal is here to wire all up with as little as possible number of files/folders so that devs can use it just for familiarization with how Temporal works (individual files for Events seems clearer at least for me). Make even beginner programmers to be able to use it out of the box, so it is ridicilously easy to test it - so not a single dev gives up. I am experienced full-stack dev, but it took me some time to connect arrange all into usable form.

React is great, but until NextJS came into picture creating on your own full webpack solution for production was very hard.
Temporal is great, but until perfect drop-in integration is not here many devs will give up on it for sure. I know that in big companies there are very capable programmers that will solve this in very short periods of time and this is where Temporal earns money by providing help. But Temporal can do so much for so many people it is pity that we do not put it in full use. Many smaller teams will gladly use Temporal Cloud when it becomes available, to be sure nothing weird happens in production because their server are run by the company that created Temporal.

/queue0/

what is queue0? why is it here in the filepath? you didnt explain why it is necessary to expose queues in the api route

Queue0 is example queue that will be linked to "One Click Buy" example page. User will create more queues. Maybe it can be renamed to "DefaultQueue" instead to make things more clear.

/queue0/worker.ts

i dont think nextjs would support workers running as API routes.. but maybe this is not a problem, need ot see your implementation to understand what you are trying to do

This is outside the /pages directory, so it is not a route like a page or API. This is independent TS file that will be run with ie. ts-node worker.ts and that is it. This file has nothing to do with NextJS at all, this is the worker, individual and fully operational. It uses only files that are inside /workflows directory. File worker.ts just imports all workflows and actions it uses to function and starts the worker with those imports.

START/CANCEL/GETSTATE

can we combine all these into one file? why so many different files, i thought you wanted absolute minimum

Because the code is verbose and if we put all in single file it will be harder to work with. Second is that we loose visiblity of what actions are available and third is that we loose direct pairing with /pages/api/workflows/queue0/START single API endpoint that dynamically loads the file. If we have 20 events that communicate with temporal instance, it will be very very hard to do it all in single file. I would never put different events to same file. When user looks at the files it should be clear at a glances what are the moving parts here without opening anything. We can do the "single file - single event" first, and then try to make it into one file later if all other fits into place properly.

Accelerate cycle time. provide simple copy/paste procedures how to add new: queue, workflow, event, activity inside single README.md file

this sounds like a GREAT idea

Cool.

provide with single bash script that will download and replace /workflows/README.md with the latest version of how-to procedures feed latest and greatest step-by-step directly into the codebase

no this is not fitting the vision of the samples, we expect our users to modify the examples heavily and so investing in tooling for them to redownload our README makes no sense

I agree. All updates can be done manually from main repo.

for step 5 Automate instead of doing crude overwrite, we should do the "Create React App" approach and encapsulate this in a dependency that we maintain. anyway, we dont have to get to step 5 yet.

I agree on this.

@swyxio
Copy link
Contributor

swyxio commented Feb 7, 2022

ok i think we are agreed on these points then!

@milanbgd011
Copy link
Author

Thank you for your feedback. Will create PR when I catch a bit of time, so you can test it locally and provide more suggestions for the final solution.

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 18, 2022

I need more feedback before PR. Am I wrong anywhere? What is not intuitive? What can be changed?

Here is my final proposal for NextJS/Temporal integration, by providing the same example in different form as https://github.com/temporalio/samples-typescript/tree/main/nextjs-ecommerce-oneclick already defined one.

Naming rules (imporant to clarify that first)

  • all events will be in UPPERCASE letters everywhere (file name, function name, ...). Redux introduced this pattern "make all events UPPERCASE", so many devs connect all uppercase to "events" naturally.
    • events include
      • api call (used to trigger event against workflow)
      • signal (used as a listener inside workflow)
      • query (used as listener inside workflow)
  • all states of workflow instance are also UPPERCASE, with "IS_" prefix, ie. "IS_PENDING", or "IS_CONFIRMED". Word "IS" is there to communicate that this IS current state
  • everything else is small letters or camelCase

File structure

Screenshot 2022-02-18 at 02 06 14

  • /__temporal/ is placed in root of NextJS app (can be anything else), prefixed with __ so that it does not mix with folders from NextJS app in existing systems. Just temporal or just workflows would mix with other existing folders. Also, we use the same name for universal API route (discussed at the bottom), like /api/__temporal/queue0/oneClickBuy/_INITIALIZE to start a workflow instance in temporal server.
    • /_activities/ folder holds functions that can talk to any public (WWW) resource, these functions are universal, unrelated to the individual queue and workflow, can be used anywhere, just needs proper input. This why I choose to move them in separate folder outside queue and workflow definition. Using _all.ts to export all existing activities which will be used from worker.ts from anywhere. Remember, activities are universal. Prefixed with _ so that it does not mix with queue folders below, but serves them all.
    • /queue0/ is random name for a queue, can be "defaultqueue" or whatever
      • /oneClickBuy/ folder separately which handles only workflow itself and events that can be sent to that workflow, very simple to understand. Here is the workflow definition that you use to create workflow instance with (using _INITIALIZE event), then use CANCEL or GETSTATE events to communicate with running workflow instance.
        • workflow.ts - definition of the "oneClickBuy" workflow (discussed in details later)
        • _INITIALIZE.ts - this one initializes workflow instance in temporal server (aka. client.start())
        • CANCEL.ts - for cancelling signal
        • GETSTATE.ts - for "getting the state" query
      • in root of each queue folder we have 2 files
        • /workflows.ts - importing all workflows from different files and exporting them as single export for worker
        • /worker.ts - gets activities from _activities and ./workflows.ts data and runs worker (nothing else)

The workflow file structure (explanations below the code)

Goal here is to have full definition of all variables in single exported d object and then use it inside the workflow file and any external file that needs that definition. I think that when developer sees this d object it can instantly know what types of data is needed for this all to work. For me, creating this object practically explained all details about a workflow (I now see all "moving parts" perfectly with a glance). Plus, autocomplete is crazy good, simplifies quite a lot.

/* eslint-disable no-void */
/* eslint-disable no-return-assign */
/* eslint-disable no-return-await */
// eslint-disable-next-line
import * as wf from '@temporalio/workflow';
import type * as activities from '../../_activities/_all';

// DEFINITION
export const d = {
  taskQueue: 'queue0',
  namespace: 'default',
  activity: wf.proxyActivities<typeof activities>({
    startToCloseTimeout: '1 minute',
  }),
  query: {},
  queryEvent: {
    GETSTATE: 'GETSTATE',
  },
  signal: {},
  signalEvent: {
    CANCEL: 'CANCEL',
  },
  state: {
    IS_PENDING: 'IS_PENDING',
    IS_CONFIRMED: 'IS_CONFIRMED',
    IS_CANCELLED: 'IS_CANCELLED',
  },
};
d.query[d.queryEvent.GETSTATE] = wf.defineQuery(d.queryEvent.GETSTATE);
d.signal[d.signalEvent.CANCEL] = wf.defineSignal(d.signalEvent.CANCEL);

// WORKFLOW
export async function _oneClickBuy(props) {
  const { itemId } = props || {};

  // define context
  const itemToBuy = itemId;
  let purchaseState = d.state.IS_PENDING;

  // wire up handlers (aka. event listeners)
  wf.setHandler(d.signal[d.signalEvent.CANCEL], () => void (purchaseState = d.state.IS_CANCELLED)); // return nothing, just change context
  wf.setHandler(d.query[d.queryEvent.GETSTATE], () => purchaseState); // return data, dont change context

  // workflow logic
  if (await wf.condition(() => purchaseState === d.state.IS_CANCELLED, '5s')) {
    return await d.activity.cancelPurchase(itemToBuy);
  }
  purchaseState = d.state.IS_CONFIRMED;
  return await d.activity.startPurchase(itemToBuy);
}

Details explaining choices for workflow file organization

  • needed to put at the very top of the file couple of eslint disable rules to stop it complaining

  • created d variable to store all definition for this workflow in single object, which autocompletes even without any typescript definitions, very fast to pull out data
    Screenshot 2022-02-18 at 00 37 45
    Screenshot 2022-02-18 at 00 37 59
    Screenshot 2022-02-18 at 00 39 41

  • then just use d to wire all up pefectly without errors

  • used d.signal key to store all signal definitions wf.defineSignal() and d.signalEvents is just ENUM definition

  • used d.query key to store all signal definitions wf.defineQuery() and d.queryEvents is just ENUM definition

  • /pages/api/__temporal/[[...slug]].ts is used as a single API endpoint to communicate with all queues/workflows, so that developer that tries to wrap his head around temporal does not waste time creating files in /api, so all code is practically in root in /__temporal (folder name is irrelevant). We simply form the URL to the /api/_temporal/... and it will trigger the execution without single line of code, if all files are named and positioned properly.

    • /api/_temporal/queue0/oneClickBuy/_INITIALIZE api call to create new workflow instance (start purchase)
    • /api/_temporal/queue0/oneClickBuy/CANCEL to cancel purchase
    • /api/_temporal/queue0/oneClickBuy/GETSTATE to get current state of the purchase
import { NextApiRequest, NextApiResponse } from 'next';

export default async (req: NextApiRequest, res: NextApiResponse): Promise<any> => {
  const { slug } = req.query || {};
  const [queue, workflow, event] = slug ? (Array.isArray(slug) ? slug : [slug]) : [];
  const DynamicAPILoader = await import(`@/__temporal/${queue}/${workflow}/${event}.ts`);
  if (DynamicAPILoader.default) {
    const [ERRreturnJSON, returnJSON] = await DynamicAPILoader.default({ req, res })
      .then((r: any) => [null, r])
      .catch((e: any) => [e]);
    if (ERRreturnJSON) {
      return res.status(500).json({ errors: [{ error: ERRreturnJSON }] });
    }
    return res.status(200).json(returnJSON);
  }

  return res.status(500).json({
    errors: [
      {
        error: 'No API endpoint!',
      },
    ],
  });
};

PS. Congrats Temporal on huge round! So much money for such small team! Temporal will crush everything now, just matter of time. 🚀🚀🚀🚀🚀

@swyxio
Copy link
Contributor

swyxio commented Feb 19, 2022

thanks for joining us for the ride!

this d god object is not really conventional. i wonder if @lorensr has any opinions about it. everything else looks like we have discussed, although you may wish to check out @vkarpov15's work on temporal-rest https://github.com/vkarpov15/temporal-rest

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 19, 2022

Thanks for the feedback!

although you may wish to check out @vkarpov15's work on temporal-rest https://github.com/vkarpov15/temporal-rest

Thanks for the link.

this d god object is not really conventional.

Try building a workflow using d and see how it feels, seems to me like missing part in whole DX. Conventions exist to be broken, but for a good reason.

Will wait for couple of days for any additional info/comments to appear. No rush, can create PR in 10 minutes, no problem.

@lorensr
Copy link
Contributor

lorensr commented Feb 19, 2022

Hi @milanbgd011, I was interested to read the most recent iteration. Thoughts:

  • d.signal[d.signalEvent.CANCEL] is harder to read than I'm used to. I usually try to optimize for readability/comprehension, but perhaps worth it for better organization in a large codebase—don't know!
  • I'm usually inclined toward the FE not needing to know backend implementation details like temporal queue. Looks like it's passing queue, workflow, event. Perhaps if the latter two are named well enough, the FE can easily understand, but there are cases in which you wouldn't want to couple. (eg workflows you don't want to be called directly by FE, or a single API endpoint that calls different workflows)
  • queue:workflow is not always 1:1 or 1:many, but can be many:many. There are a number of cases in which a workflow is run on different queues. Perhaps replace /queue0/ with /workflows/ and /workers/?

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 20, 2022

Hi @lorensr

thank you for very insightful info. This is type of info I needed. Thank you @sw-yx for pinging @lorensr on this, got really good feedback here!

1. FILE REORGANIZATION PROPOSAL

I moved files around quickly (just reorder, no coding in there) just to see if I understood you right, can you confirm that this is what you were suggesting? (implementation is not important for now, just the organization of the files)

Screenshot 2022-02-20 at 01 07 56

2. UNIVERSAL API ENDPOINT REFACTOR

Incorporating the new folder structure and info you provided, maybe this is the best pattern. This way we can put same workflow to different queues and namespaces easy, all is decoupled:

  • /api/__temporal/WORKFLOW/EVENT/QUEUE/NAMESPACE pattern
  • /api/__temporal/oneClickBuy/_INITIALIZE/queue0 (adding queue in URL at the end)
  • /api/__temporal/oneClickBuy/_INITIALIZE/queue0/default (adding namespace after queue, "default" is default)

3. WORKFLOW CODE REFACTOR

ORIGINAL VERSION (NEXTJS EXAMPLE)

This works, but seems very messy and unreadable to me at least. Very hard to write without making errors because of writing strings manually everywhere. Not ideal.

import * as wf from '@temporalio/workflow';
// // Only import the activity types
import type * as activities from './activities';

const { checkoutItem, canceledPurchase } = wf.proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

type PurchaseState = 'PURCHASE_PENDING' | 'PURCHASE_CONFIRMED' | 'PURCHASE_CANCELED';

export const cancelPurchase = wf.defineSignal('cancelPurchase');
export const purchaseStateQuery = wf.defineQuery<PurchaseState>('purchaseState');

// @@@SNIPSTART typescript-oneclick-buy
export async function OneClickBuy(itemId: string) {
  const itemToBuy = itemId;
  let purchaseState: PurchaseState = 'PURCHASE_PENDING';
  wf.setHandler(cancelPurchase, () => void (purchaseState = 'PURCHASE_CANCELED'));
  wf.setHandler(purchaseStateQuery, () => purchaseState);
  if (await wf.condition(() => purchaseState === 'PURCHASE_CANCELED', '5s')) {
    return await canceledPurchase(itemToBuy);
  } else {
    purchaseState = 'PURCHASE_CONFIRMED';
    return await checkoutItem(itemToBuy);
  }
}

REFACTORED VERSION PROPOSAL

This seems to me much easier to read and figure out what is going on, plus not a single string needs to be written. All is automatically suggested and type safe 100%. Please notice the change from d.signal[d.signalEvent.CANCEL] to d.signal.CANCEL.handler. This implementation needs dI and definitionInit functions to be placed in separate file and used in each workflow without change.

import * as wf from '@temporalio/workflow';
import type * as activities from '../../activities/_all';

// DEFINITION
const states = ['IS_PENDING', 'IS_COMPLETED', 'IS_CANCELLED'] as const;
const queries = ['GETSTATE'] as const;
const signals = ['CANCEL'] as const;
export const d: dI = definitionInit(states, queries, signals);

// WORKFLOW
export async function oneClickBuy(props) {
  // context
  const itemToBuy = props.itemId;
  let purchaseState = d.state.IS_PENDING;

  // listeners
  wf.setHandler(d.signal.CANCEL.handler, () => void (purchaseState = d.state.IS_CANCELLED)); // return nothing, just change context
  wf.setHandler(d.query.GETSTATE.handler, () => purchaseState); // return data, dont change context

  // workflow
  if (await wf.condition(() => purchaseState === d.state.IS_CANCELLED, '5s')) {
    return await d.activity.cancelPurchase(itemToBuy);
  }
  purchaseState = d.state.IS_CONFIRMED;
  return await d.activity.startPurchase(itemToBuy);
}

This creates fully typed definition for d, see screenshots. We need handler (used in workflow.ts file) and key (used in CANCEL.ts and GETSTATE.ts files) for each query and signal, this is why I chose to make those keys. Everything is wired up automatically, using the predefined arrays for states, queries and signals, no further work needed. Very easy to add and remove stuff. If you remove some element from array (states,queries,signals), typescript will immediately show you stuff that will break. Screenshots:

Screenshot 2022-02-20 at 03 05 10
Screenshot 2022-02-20 at 03 05 17

Screenshot 2022-02-20 at 03 06 31
Screenshot 2022-02-20 at 03 06 37

Screenshot 2022-02-20 at 02 21 33
Screenshot 2022-02-20 at 02 21 38
Screenshot 2022-02-20 at 02 21 44

If you put .handler instead of .key in GETSTATE.ts or CANCEL.ts files, it will complain too. One less place to think about errors:

Screenshot 2022-02-20 at 02 40 59
Screenshot 2022-02-20 at 02 41 06

HELPER FUNCTIONS FOR "REFACTORED VERSION"
function definitionInit(statesInput, queriesInput, signalsInput) {
  return {
    activity: wf.proxyActivities<typeof activities>({ startToCloseTimeout: '1 minute' }),
    states: statesInput.reduce((acc, key) => ({ ...acc, [key]: key }), {}),
    queries: queriesInput.reduce((acc, key) => ({ ...acc, key, query: d.query[key] }), {}),
    signals: signalsInput.map((acc, key) => ({ ...acc, key, handler: d.signal[key] }), {}),
  };
}
type dI = {
  activity: typeof activities;
  states: Record<typeof states[number], typeof states[number]>;
  query: Record<typeof queries[number], { key: string; handler: () => any }>;
  signal: Record<typeof signals[number], { key: string; handler: () => any }>;
};

PS. Concerning the FE accessing all. We can expose all routes to the FE, but require specific HASH to be passed in order for it to trigger it, thus preventing the FE to execute something he has no right to do, so this is not hard to do, will think about it last. This example should not think about security at all, this is "figure out Temporal and create mental model" example, that is all. Then it will be pushed to more custom solution from there.

PPS. I know this solution will have rough edges somewhere and will not cover all scenarios, but lets push this to its limits, maybe it will be good starting point for some better solution that someone else will suggest. Goal here is to give the best possible starting point for familiarizing with Temporal. Then user will move files around, customize and create fully custom solution for them. It is important to know what is goal here.

@lorensr
Copy link
Contributor

lorensr commented Feb 20, 2022

Some workflows don't have states or queries or signals, so I'd make a single object param. Also prefer verbs starting function names. I'd say defineWorkflow, but technically the definition is the name & function. describeWorkflow? Then d is a WorkflowDescription.

export const d = describeWorkflow({ states, queries, signals });

Could also have a /workflows/index.ts that imported all workflows.

Don't need .key and .signal: if d.signal.CANCEL is a SignalDefinition, d.signal.CANCEL.name is the string: d.signal.CANCEL

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 20, 2022

Thank you @lorensr for the feedback! Means a lot.

I refactored all as you suggested, seems like all is incorporated into the code now.

Autocomplete works fully, it is very fast to create workflow when all is typed 100%.

Tested, all works flawlessly.

REFACTORED FILE STRUCTURE

Created helpers.ts in root of __temporal where describeWorkflow function and dI type are stored. All seems decoupled now. Very clear and intuitive folder/file structure. This can grow a lot before needing to customize to fit specific needs. I unified naming to _all.ts for /activites/_all.ts and /workflows/_all.ts so that they are named the same. Using _all.ts in activites to separate them from real activities, index.ts would mix. I think this is ok.

__temporal __temporal/__worker_source__
Screenshot 2022-02-21 at 04 38 14 Screenshot 2022-02-21 at 04 38 56
npm run temporal to run tsc compiler in watch mode and queue worker also in watch mode (reload on any change) in development env node __temporal/__worker_source__/workers/queue0.js to start worker in production from compiled ts>js

REFACTORED WORKFLOW

Needed to add params to dI type, so they can be passed to helper function. This seems right to me. Only 4 lines of code to define everything for a single workflow. Easy to see all states, queries and signals involved in that workflow. The moment you add new key to any of the states/queries/signals, you get autocomplete. If you delete one of the keys from those array, typescript will warn you about errors. Mental model here is very easy, at least for me.

PS. When I remove comments, code has only 19 lines, exactly the same as original workflow for nextjs-oneclick example.

import * as wf from '@temporalio/workflow';
import { describeWorkflow, dI } from '../../helpers';
import type * as activities from '../../activities/_all';

// DEFINITION
const states = ['IS_PENDING', 'IS_COMPLETED', 'IS_CANCELLED'] as const;
const queries = ['GETSTATE'] as const;
const signals = ['CANCEL'] as const;
export const d: dI<typeof activities, typeof states[number], typeof queries[number], typeof signals[number]> =
  describeWorkflow({ states, queries, signals });

// WORKFLOW
export async function oneClickBuy(props) {
  // LISTENERS
  wf.setHandler(d.signal.CANCEL, () => void (stateCurrent = d.state.IS_CANCELLED));
  wf.setHandler(d.query.GETSTATE, () => stateCurrent);

  // CONTEXT
  let stateCurrent: typeof states[number] = d.state.IS_PENDING; // initial state
  const itemId: string = props.itemId;

  // WORKFLOW LOGIC
  if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '5s')) {
    return await d.activity.cancelPurchase({ itemId });
  }
  stateCurrent = d.state.IS_COMPLETED;
  return await d.activity.checkoutItem({ itemId });
}

REFACTORED HELPER FUNCTION (./helpers.ts)

Type dI uses params. Imported the SignalDefinition and QueryDefinition.

// @ts-nocheck

// eslint-disable-next-line
import * as wf from '@temporalio/workflow';
// eslint-disable-next-line
import { SignalDefinition, QueryDefinition } from '@temporalio/common';

export type dI<A, B, C, D> = {
  activity: A;
  state: Record<B, B>;
  query: Record<C, QueryDefinition<B>>;
  signal: Record<D, SignalDefinition>;
};

export function describeWorkflow({ states, queries, signals }): any {
  return {
    activity: wf.proxyActivities({ startToCloseTimeout: '1 minute' }),
    state: states.reduce((acc, key) => ({ ...acc, [key]: key }), {}),
    query: queries.reduce((acc, key) => ({ ...acc, [key]: wf.defineQuery(key) }), {}),
    signal: signals.reduce((acc, key) => ({ ...acc, [key]: wf.defineSignal(key) }), {}),
  };
}

Needed to add @ts-nocheck because B/C/D are complaining and really dont know how to throw out that "symbol" so it does not complain.

Screenshot 2022-02-21 at 04 02 52

REFACTORED UNIFIED API ENDPOINT

This is what I saw looking at the code:

  • namespace is used in _INITIALIZE, CANCEL and GETSTATE
  • queue name is used only in _INITIALIZE

That being said, I will alter the proposal for unified API, putting namespace first in the URL, then queue:

  • /api/__temporal/WORKFLOW/EVENT/NAMESPACE/QUEUE
  • /api/__temporal/oneClickBuy/_INITIALIZE/default/queue0 (only here we need to provide queue name)
  • /api/__temporal/oneClickBuy/CANCEL/important (specify ie. "important" namespace manually)
  • /api/__temporal/oneClickBuy/GETSTATE/imporant (specify ie. "important" namespace manually)
  • /api/__temporal/oneClickBuy/CANCEL (will append default as default namespace if not defined)
  • /api/__temporal/oneClickBuy/GETSTATE (will append default as default namespace if not defined)

@milanbgd011
Copy link
Author

milanbgd011 commented Feb 23, 2022

@lorensr

I added workflow versioning and sinks in this more advanced example, we can make separate full example with all features integrated. On one side we have "simple" workflow and on the other "all included" workflow, so user can add/delete parts needed. Seems like reasonable thing to have.

Let me know, when you have some time, what can be polished, so I can create PR for this finally.

import * as wf from '@temporalio/workflow';
import { describeWorkflow, dI } from '../../helpers';
import type * as activities from '../../activities/_all';
import { logger } from '../../sinks';

// DEFINITION
const states = ['IS_PENDING', 'IS_COMPLETED', 'IS_CANCELLED'] as const;
const queries = ['GETSTATE'] as const;
const signals = ['CANCEL'] as const;
const workflowVersion = {
  'Initial workflow': 'v1', // initial workflow
  'Increase timeout to 15sec': 'v2', // increase timeout for buy: 5 > 15 seconds
};
export const d: dI<typeof activities, typeof states[number], typeof queries[number], typeof signals[number]> =
  describeWorkflow({ states, queries, signals });

// WORKFLOW
export async function oneClickBuy(props) {
  // LISTENERS
  wf.setHandler(d.signal.CANCEL, () => void (stateCurrent = d.state.IS_CANCELLED));
  wf.setHandler(d.query.GETSTATE, () => stateCurrent);

  // CONTEXT
  let stateCurrent: typeof states[number] = d.state.IS_PENDING; // initial state
  const { itemId } = props || {};

  // WORKFLOW
  // --- v1 only (initial)
  if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '5s')) {
    logger.info(`Log to worker console: Cancelled the purchase! Status: ${stateCurrent}`); // for debugging via console or more..
    return await d.activity.cancelPurchase({ itemId });
  }

  // // --- v1/v2 running same time (value of patch will be "v2", simple version and we see desc in code)
  // if (wf.patched(workflowVersion['Increase timeout to 15sec'])) {
  //   if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '15s')) {
  //     return await d.activity.cancelPurchase(itemToBuy);
  //   }
  // } else if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '5s')) {
  //   return await d.activity.cancelPurchase(itemToBuy);
  // }

  // // --- only v2, with deprecate patch
  // wf.deprecatePatch(workflowVersion['Increase timeout to 15sec']);
  // if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '15s')) {
  //   return await d.activity.cancelPurchase(itemToBuy);
  // }

  // // --- only v2
  // if (await wf.condition(() => stateCurrent === d.state.IS_CANCELLED, '15s')) {
  //   return await d.activity.cancelPurchase(itemToBuy);
  // }

  stateCurrent = d.state.IS_COMPLETED;
  logger.info(`Log to worker console: Completed the purchase! Status: ${stateCurrent}`); // for debugging via console or more..
  return await d.activity.checkoutItem({ itemId });
}

Here is the /sinks.ts file

// eslint-disable-next-line
import * as wf from '@temporalio/workflow';

export interface LoggerSinks extends wf.Sinks {
  logger: {
    error(message: string): void;
    info(message: string, data?: unknown): void;
  };
}
export const { logger } = wf.proxySinks<LoggerSinks>();

Screenshot 2022-02-23 at 07 17 12

@lorensr
Copy link
Contributor

lorensr commented Feb 24, 2022

Can we get d to have the right type just with this?

export const d = describeWorkflow({ states, queries, signals });

If not, then what about something like this:

export const d = describeWorkflow<{activities, states, queries, signals}>({ states, queries, signals });

(Trying to get it shorter than current)

Nice version description-in-code!

Can you host the code in your own repo and submit a PR to add it to samples-typescript/README.md, similar to most of these?

image

(We're committed to keeping the samples in this repo up to date, and I want to protect the maintainer team time for working on the SDK)

@swyxio
Copy link
Contributor

swyxio commented Feb 24, 2022

and I'd be happy to do a blogpost/video chat with you to walk through some of these best practices that you've highlighted!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants