Level 2 · Lesson 2
Accept fast, cook later
Level 2 · Lesson 2Save 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.
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.
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
Locked until the step before it is done.
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.
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:
- 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.
npm install @forge/kvs @forge/eventspackage.jsonnpm 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" |
Stuck? “Cannot find module @forge/…” · I added an npm package
Next step: next
Step 3: The desk uses the rail now
Locked until the step before it is done.
The desk no longer writes tickets itself, so it swaps the ticket helpers for the new module:
src/desk.jsThe 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(); |
|
Next step: next
Step 4: Check before using the restaurant’s badge
Locked until the step before it is done.
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.
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:
src/desk.jsAsk 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); |
Stuck? Creating a ticket fails with “Jira answered 400”
Next step: next
Step 5: Accept fast and hand back a receipt
Locked until the step before it is done.
submitMenu now checks, saves, pushes and returns the receipt. A second resolver, getMenu, lets the desk follow it:
src/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”
Next step: next
Step 6: Write a ticket on the restaurant’s badge
Locked until the step before it is done.
The ticket-writing code moves into its own file, now as the app. The space always comes from settings, never from the menu:
src/kitchen/createTicket.jsA 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()
Next step: next
Step 7: The prep cook works the rail
Locked until the step before it is done.
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.
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.
src/worker.jsA 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: } |
Stuck? My menu stays “waiting” or ends “failed”
Next step: next
Step 8: Put the rail and the prep cook on the licence
Locked until the step before it is done.
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.
manifest.ymlChange 1 of 3A 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 |
manifest.ymlChange 2 of 3The 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 |
manifest.ymlChange 3 of 3A 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
Next step: next
Step 9: Quiet the bell for the kitchen’s own tickets
Locked until the step before it is done.
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:
manifest.ymlTickets 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
Next step: next
Step 10: Build, deploy and sign the new licence
Locked until the step before it is done.
storage:app is a new scope, so this is a major version:
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgradeStuck? Deploy stops after a permission change · I changed the menu desk (Level 2) · The tunnel is running while I deploy or upgrade
Next step: next
Step 11: Send the menu and take a receipt
Locked until the step before it is done.
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
Next step: next
Compare with yours: download the code after this lesson.