Course menuForge Kitchen

Level 2 · Lesson 2

Accept fast, cook later

Save the menu, put a job slip on a queue and hand back a receipt at once. A consumer function, the prep cook, writes the tickets afterwards on the app’s own badge.

About 45 min11 steps

Behind, or starting here? Download the code as it should be before this lesson.

Right now the head chef waits while the resolver talks to Jira. That is fine for one ticket and not for eight, and a function called from a page has only a few seconds before Forge stops it. This lesson splits the work: the desk accepts the menu and answers at once, and a separate function does the slow part later.

Feel the wait

The same menu goes to two desks: the one from lesson 1, and the one this lesson builds. Press the button and compare how long each one keeps the head chef waiting.

about 3 sSimulated timings

Lesson 1 · Wait for Jira asUser()

The resolver writes the ticket itself, then answers.

Button busy for 0.0 s

Lesson 2 · Take a receipt asApp()

The resolver saves the menu, puts { menuId } on the rail and answers. The prep cook writes the ticket later.

Button busy for 0.0 s

Step 1: Add storage and the rail

Runs in the Atlassian cloud · Back of house

Key-Value Store (KVS) · labelled jars

The Key-Value Store keeps JSON values under string keys, hosted by Forge, from @forge/kvs. Each installation has its own store, so two sites never see each other’s data. Use a prefix in each key, such as menu:, to keep different kinds of record apart. A value can be up to about 240 KiB.

In the kitchen A shelf of jars. Write the label, put something in, find it by its label later.

Atlassian docs: Storage reference (opens in a new tab)

Runs in the Atlassian cloud · Back of house

Queue (@forge/events) · the rail

A queue, from @forge/events, takes a small message now and hands it to a consumer function later. The function that pushed it answers straight away. The consumer runs on its own, and can be given a longer time limit than a function called from a page:

Seconds before Forge stops the function, drawn to scale. The kind of invocation sets the limit.
  • UI calls (invoke from a page or panel) and product events25 s
  • Web triggers, Rovo actions, scheduled triggers and async events, by default55 s
  • The prep cook in this course (timeoutSeconds: 120)120 s (2 min)
  • A queue consumer with timeoutSeconds, at most900 s (15 min)

The rule: A click is a short function. A job belongs on a queue. A process that must stay up belongs in a container.

In the kitchen The rail where job slips wait until the prep cook takes them down.

Atlassian docs: Events (opens in a new tab)

RunTerminal
npm install @forge/kvs @forge/events
Editpackage.json

npm install @forge/events @forge/kvs writes these lines.

"dependencies": {
"@forge/api": "^8.2.0",
"@forge/bridge": "^7.1.0",
Added: "@forge/events": "^3.0.7",
Added: "@forge/kvs": "^2.0.7",
"@forge/react": "^12.3.0",
"@forge/resolver": "^2.0.0",
"react": "^18.2.0"

Why: Key-Value Store (KVS) · Queue (@forge/events)

Stuck? “Cannot find module @forge/…” · I added an npm package

Step 3: The desk uses the rail now

The desk no longer writes tickets itself, so it swaps the ticket helpers for the new module:

Editsrc/desk.js

The desk no longer writes tickets itself.

import Resolver from '@forge/resolver';
import api, { route } from '@forge/api';
Removed: import { LABELS, settings } from './lib/config';
Removed: import { paragraphs } from './lib/adf';
Added: import { settings } from './lib/config';
import { validateMenu } from './lib/validate';
Added: import { acceptMenu, getMenu } from './menus';
const resolver = new Resolver();

Why: Queue (@forge/events)

Step 4: Check before using the restaurant’s badge

Runs in the Atlassian cloud · Back of house

asApp() · on the restaurant’s own badge

asApp() calls a product API as the app itself, with the app’s scopes and no person attached. A consumer has nobody to act for, so it must use it. The app may be allowed more than the person who asked, so check the person’s own permission before the app acts for them.

In the kitchen When nobody is there to lend a badge, the kitchen uses the restaurant’s own.

Atlassian docs: Product REST APIs (opens in a new tab)

The prep cook will write tickets as the app. Before accepting a menu, ask Jira whether the person sending it could create work items in the Kitchen space themselves:

Editsrc/desk.js

Ask Jira, as the person, whether they may create work items in the project.

const resolver = new Resolver();
Added: // The prep cook will create tickets on the restaurant's own badge. Before that,
Added: // check that the person asking could create work items in the project themselves.
Added: async function canCreateIn(projectKey) {
Added: const response = await api
Added: .asUser()
Added: .requestJira(route`/rest/api/3/mypermissions?projectKey=${projectKey}&permissions=CREATE_ISSUES`);
Added: if (!response.ok) return false;
Added: const data = await response.json();
Added: return data.permissions?.CREATE_ISSUES?.havePermission === true;
Added: }
Added:
// One ticket for the whole menu, created on the head chef's own badge.
resolver.define('submitMenu', async ({ payload }) => {
const check = validateMenu(payload);

Why: asApp() · asUser()

Stuck? Creating a ticket fails with “Jira answered 400”

Step 5: Accept fast and hand back a receipt

submitMenu now checks, saves, pushes and returns the receipt. A second resolver, getMenu, lets the desk follow it:

Editsrc/desk.js
  • submitMenu returns as soon as the slip is on the rail.
  • getMenu lets only the sender follow their receipt.
return data.permissions?.CREATE_ISSUES?.havePermission === true;
}
Removed: // One ticket for the whole menu, created on the head chef's own badge.
Removed: resolver.define('submitMenu', async ({ payload }) => {
Added: // Accept fast: check, save, queue, and return a receipt straight away.
Added: resolver.define('submitMenu', async ({ payload, context }) => {
const check = validateMenu(payload);
if (!check.ok) return { error: check.error };
Removed: const { projectKey, issueType } = settings();
Removed: const { title, notes } = check.menu;
Removed: const response = await api.asUser().requestJira(route`/rest/api/3/issue`, {
Removed: method: 'POST',
Removed: headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
Removed: body: JSON.stringify({
Removed: fields: {
Removed: project: { key: projectKey },
Removed: issuetype: { name: issueType },
Removed: summary: title,
Removed: description: paragraphs(notes),
Removed: labels: [LABELS.MENU],
Removed: },
Removed: }),
Removed: });
Removed: if (!response.ok) {
Removed: return { error: `Jira answered ${response.status}: ${await response.text()}` };
Added: const { projectKey } = settings();
Added: if (!(await canCreateIn(projectKey))) {
Added: return { error: `You can’t create work items in ${projectKey}, so the kitchen won’t either.` };
}
Removed: const issue = await response.json();
Removed: return { ticket: { key: issue.key, dish: title } };
Added: const menuId = await acceptMenu({ ...check.menu, requestedBy: context.accountId });
Added: return { menuId };
});
Added: // Only the person who sent a menu can follow its receipt.
Added: resolver.define('getMenu', async ({ payload, context }) => {
Added: const menu = typeof payload?.menuId === 'string' ? await getMenu(payload.menuId) : undefined;
Added: if (!menu || menu.requestedBy !== context.accountId) return { error: 'No menu with that receipt.' };
Added: return { status: menu.status, tickets: menu.tickets ?? [], page: menu.page ?? null, error: menu.error ?? null };
Added: });
Added:
export const handler = resolver.getDefinitions();

Why: Job id · Product context

getMenu compares context.accountId with the person who sent the menu, so a receipt id on its own shows nothing to anyone else.

Stuck? My menu stays “waiting” or ends “failed”

Step 6: Write a ticket on the restaurant’s badge

The ticket-writing code moves into its own file, now as the app. The space always comes from settings, never from the menu:

New filesrc/kitchen/createTicket.js

A new file: the project always comes from settings, never from the browser.

Added: import api, { route } from '@forge/api';
Added: import { LABELS, LIMITS, settings } from '../lib/config';
Added: import { paragraphs } from '../lib/adf';
Added:
Added: /**
Added: * Creates one dish ticket on the restaurant's own badge (asApp). No user is
Added: * present in a queue consumer. The project always comes from settings.
Added: */
Added: export async function createTicket({ summary, notes, specialist = false }) {
Added: const { projectKey, issueType } = settings();
Added: const labels = specialist ? [LABELS.MENU, LABELS.SPECIALIST] : [LABELS.MENU];
Added: const response = await api.asApp().requestJira(route`/rest/api/3/issue`, {
Added: method: 'POST',
Added: headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
Added: body: JSON.stringify({
Added: fields: {
Added: project: { key: projectKey },
Added: issuetype: { name: issueType },
Added: summary: summary.slice(0, LIMITS.SUMMARY),
Added: description: paragraphs(notes),
Added: labels,
Added: },
Added: }),
Added: });
Added: if (!response.ok) {
Added: throw new Error(`Jira answered ${response.status}: ${await response.text()}`);
Added: }
Added: const issue = await response.json();
Added: return issue.key;
Added: }

Why: asApp()

Step 7: The prep cook works the rail

Runs in the Atlassian cloud · Back of house

Consumer (worker function) · the prep cook

A consumer is the function a consumer module runs for each event on its queue. It starts after the resolver has already answered, gets the event’s body, and has its own time limit. If it throws, Forge can run it again with the same event, so it must be safe to run twice.

In the kitchen The prep cook takes slips off the rail one at a time and does the long work.

Later, Forge runs the consumer function with the event.Later, the prep cook takes the slip off the rail.

For now the prep cook writes one ticket for the whole menu, as the desk did, and records progress in the menu’s jar as it goes. The desk shows those statuses.

New filesrc/worker.js

A new file: one run per job slip, after submitMenu has returned.

Added: import { getMenu, saveMenu } from './menus';
Added: import { createTicket } from './kitchen/createTicket';
Added:
Added: // The prep cook. Forge runs this once per job slip on the menu-jobs rail, after
Added: // submitMenu has already returned. A slip can arrive more than once, so every
Added: // step checks the checkpoint before doing work.
Added: export async function run(event) {
Added: const { menuId } = event.body;
Added: const menu = await getMenu(menuId);
Added: if (!menu) {
Added: console.error(`Prep cook: no menu ${menuId}`);
Added: return;
Added: }
Added: const checkpoint = menu.checkpoint ?? {};
Added: const save = (status, extra = {}) => saveMenu(menuId, { ...menu, ...extra, status, checkpoint });
Added: try {
Added: await save('cooking');
Added: if (!checkpoint.menuTicket) {
Added: checkpoint.menuTicket = await createTicket({ summary: menu.title, notes: menu.notes });
Added: await save('cooking');
Added: }
Added: await save('done', { tickets: [{ key: checkpoint.menuTicket, dish: menu.title }] });
Added: } catch (error) {
Added: console.error(`Prep cook: menu ${menuId} failed:`, error);
Added: await save('failed', { error: error.message });
Added: throw error;
Added: }
Added: }

Why: Consumer (worker function) · Checkpoint

Stuck? My menu stays “waiting” or ends “failed”

Step 8: Put the rail and the prep cook on the licence

Three changes: the consumer, which joins the rail to the prep cook; the prep cook’s function with a longer timer; and the scope for storage. The queue’s name, menu-jobs, must match new Queue({ key: 'menu-jobs' }) exactly.

Editmanifest.ymlChange 1 of 3

A consumer: the menu-jobs rail is worked by menu-worker.

function: bell
events:
- avi:jira:created:issue
Added: consumer:
Added: - key: menu-consumer
Added: queue: menu-jobs
Added: function: menu-worker
function:
- key: resolver
handler: index.handler
Editmanifest.ymlChange 2 of 3

The prep cook’s function, with a longer timer.

handler: bell.run
- key: desk-resolver
handler: desk.handler
Added: - key: menu-worker
Added: handler: worker.run
Added: timeoutSeconds: 120
resources:
- key: main
path: src/frontend/index.jsx
Editmanifest.ymlChange 3 of 3

A key to the labelled jars.

scopes:
- read:jira-work
- write:jira-work
Added: - storage:app

Why: Queue (@forge/events) · Consumer (worker function) · Runtime limits · Key-Value Store (KVS)

Stuck? Sending a menu fails with “400 Bad Request” from the queue · I changed manifest.yml

Step 9: Quiet the bell for the kitchen’s own tickets

Declared in manifest.yml · The licence

ignoreSelf · the bell ignores the restaurant’s own badge

ignoreSelf: true in a trigger’s filter drops events that the app itself caused. Without it, every ticket the app writes rings its own bell, and a trigger that edits what it listens to can run in a loop.

In the kitchen If the kitchen itself changed the ticket, the bell stays quiet.

The Level 1 bell rings for every new work item, including the ones the prep cook now writes. Let it ignore the app’s own:

Editmanifest.yml

Tickets the app creates no longer ring the bell.

function: bell
events:
- avi:jira:created:issue
Added: filter:
Added: ignoreSelf: true
consumer:
- key: menu-consumer
queue: menu-jobs

Why: ignoreSelf · Product trigger

Stuck? I changed manifest.yml

Step 10: Build, deploy and sign the new licence

storage:app is a new scope, so this is a major version:

RunTerminal
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgrade

Stuck? Deploy stops after a permission change · I changed the menu desk (Level 2) · The tunnel is running while I deploy or upgrade

Step 11: Send the menu and take a receipt

Open the menu desk and send the Spring menu again. The button is free almost at once, and the desk shows the receipt moving: on the rail, writing dish tickets, done. Open the new ticket: its reporter is the app, not you. forge logs shows no bell line for it.

Stuck? My menu stays “waiting” or ends “failed” · Sending a menu fails with “400 Bad Request” from the queue

Compare with yours: download the code after this lesson.

Type at least two letters.