[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"docs-navigation":3,"docs-page-background-jobs":166},[4,38,57,65,114,138,157],{"id":5,"group":6,"pages":7},"get-started","Get started",[8,13,18,23,28,33],{"path":9,"title":10,"description":11,"navTitle":10,"group":6,"order":12},"\u002Fdocs\u002Fgetting-started","Getting started","Create your app from the kit, run it locally with no credentials, make yourself superadmin and find where your own code goes.",1,{"path":14,"title":15,"description":16,"navTitle":15,"group":6,"order":17},"\u002Fdocs\u002Flayout","Project structure","Where code lives by where it runs, and which server\u002F directories turn a new file into a route, a job or a provider.",2,{"path":19,"title":20,"description":21,"navTitle":20,"group":6,"order":22},"\u002Fdocs\u002Fconfiguration","Configuration","starter.config.ts switches each part of the kit and its providers, environment variables hold the secrets: every key and what it changes.",3,{"path":24,"title":25,"description":26,"navTitle":25,"group":6,"order":27},"\u002Fdocs\u002Ftemplate","Updating from the kit","Keep the kit as a git remote and take each update as one commit you review, with pnpm template:update, then resolve what conflicts.",4,{"path":29,"title":30,"description":31,"navTitle":30,"group":6,"order":32},"\u002Fdocs\u002Fdeployment","Deployment","Ship the app as one Cloudflare Worker: create the resources, set the secrets, deploy main from GitHub Actions or your machine.",5,{"path":34,"title":35,"description":36,"navTitle":35,"group":6,"order":37},"\u002Fdocs\u002Fmcp","MCP server","Connect Claude Code, Cursor or VS Code to https:\u002F\u002Fnuxter.app\u002Fmcp, so your agent searches and reads these docs and their design notes.",6,{"id":39,"group":40,"pages":41},"accounts","Accounts",[42,47,52],{"path":43,"title":44,"description":45,"navTitle":44,"group":40,"order":46},"\u002Fdocs\u002Fauth","Auth","Pick the sign-in methods, add an OAuth provider, protect a route, and handle sessions, second factors and account deletion.",7,{"path":48,"title":49,"description":50,"navTitle":49,"group":40,"order":51},"\u002Fdocs\u002Ftenancy","Organizations","Scope routes and pages to an organization, and work with its members, roles, seats, invitations and a second level such as branches.",8,{"path":53,"title":54,"description":55,"navTitle":54,"group":40,"order":56},"\u002Fdocs\u002Fpermissions","Roles and permissions","Routes check permissions that an account’s role or its organization’s plan grants: checking one, adding a role, and the staff tools.",9,{"id":58,"group":59,"pages":60},"payments","Payments",[61],{"path":62,"title":59,"description":63,"navTitle":59,"group":59,"order":64},"\u002Fdocs\u002Fpayments","Sell plans through Stripe, Polar, Lemon Squeezy or Creem: define plans, connect a provider, gate features, trials, coupons and refunds.",10,{"id":66,"group":67,"pages":68},"building-blocks","Building blocks",[69,74,79,84,89,94,99,104,109],{"path":70,"title":71,"description":72,"navTitle":71,"group":67,"order":73},"\u002Fdocs\u002Fdatabase","Database","Postgres through Drizzle, on Hyperdrive in production and PGlite locally: add a table, query, transactions, migrations and seed data.",11,{"path":75,"title":76,"description":77,"navTitle":76,"group":67,"order":78},"\u002Fdocs\u002Fbackground-jobs","Background jobs","Run work after the response on Cloudflare Queues with retries: add and enqueue a job, run jobs locally, and schedule cron tasks.",12,{"path":80,"title":81,"description":82,"navTitle":81,"group":67,"order":83},"\u002Fdocs\u002Fevents","Events","React to what the kit does by adding a file to server\u002Flisteners\u002F, fire events of your own, and look up every event’s payload.",13,{"path":85,"title":86,"description":87,"navTitle":86,"group":67,"order":88},"\u002Fdocs\u002Femail","Email","Set up sending, send your own mail with deliverEmail, change the templates and wording, handle unsubscribes and add a provider.",14,{"path":90,"title":91,"description":92,"navTitle":91,"group":67,"order":93},"\u002Fdocs\u002Fcontact","Contact","A \u002Fcontact page that mails you a message or shows your address after a Turnstile check: choose the channels, set the address, know the limits.",15,{"path":95,"title":96,"description":97,"navTitle":96,"group":67,"order":98},"\u002Fdocs\u002Fnotifications","Notifications","The dashboard bell: add a notification type, send one with notify(), and how categories, opt-outs and retention apply.",16,{"path":100,"title":101,"description":102,"navTitle":101,"group":67,"order":103},"\u002Fdocs\u002Fstorage","Storage","Uploads in R2 with one policy per purpose: add a purpose, accept an upload, replace or delete a file, and serve it to its owner.",17,{"path":105,"title":106,"description":107,"navTitle":106,"group":67,"order":108},"\u002Fdocs\u002Ffeature-flags","Feature flags","Declare typed flags in starter.config.ts, read them with flag() or useFlag(), roll them out by rule or percentage, and add a provider.",18,{"path":110,"title":111,"description":112,"navTitle":111,"group":67,"order":113},"\u002Fdocs\u002Fai","AI","Call models from server code by role: text, images, speech, transcription, embeddings and video, with provider fallbacks.",19,{"id":115,"group":116,"pages":117},"web","Web",[118,123,128,133],{"path":119,"title":120,"description":121,"navTitle":120,"group":116,"order":122},"\u002Fdocs\u002Fsecurity-headers","Security headers","The Content-Security-Policy and hardening headers on every response: allow another origin, keep tokens out of Referer, accept cross-site posts.",20,{"path":124,"title":125,"description":126,"navTitle":125,"group":116,"order":127},"\u002Fdocs\u002Frate-limiting","Rate limiting","Cap how often a caller hits a route with requireRateLimit, count only failures, space out sends, and protect the zone.",21,{"path":129,"title":130,"description":131,"navTitle":130,"group":116,"order":132},"\u002Fdocs\u002Fanalytics","Analytics","Product analytics and ad conversions sent from the server with no browser script: add an event, connect a provider, and handle consent.",22,{"path":134,"title":135,"description":136,"navTitle":135,"group":116,"order":137},"\u002Fdocs\u002Fobservability","Observability","Find a failed request by its id, send faults to Sentry or Better Stack, report an error you caught, and change the error page.",23,{"id":139,"group":140,"pages":141},"content-and-look","Content and look",[142,147,152],{"path":143,"title":144,"description":145,"navTitle":144,"group":140,"order":146},"\u002Fdocs\u002Fblog","Blog","Markdown posts with an RSS feed per language, and workflows that draft posts with an AI model and open each one as a pull request.",24,{"path":148,"title":149,"description":150,"navTitle":149,"group":140,"order":151},"\u002Fdocs\u002Flanguages","Languages","Add or remove a language, write copy in the locale files, and write code, refusals and links that follow the reader’s language.",25,{"path":153,"title":154,"description":155,"navTitle":154,"group":140,"order":156},"\u002Fdocs\u002Ftheme","Theme","Change the brand colour and font, and build dashboard sections, list pages, loading placeholders and charts that match the kit.",26,{"id":158,"group":159,"pages":160},"quality","Quality",[161],{"path":162,"title":163,"description":164,"navTitle":163,"group":159,"order":165},"\u002Fdocs\u002Ftesting","Testing","Unit tests in Node, component tests in a real Nuxt app and Playwright end to end: test a route or a component, and what CI runs.",27,{"page":167,"surround":1831,"linksToKit":308},{"path":75,"title":76,"description":77,"navTitle":76,"group":67,"seo":168,"body":170},{"title":169,"description":77},"Nuxt background jobs on Cloudflare Queues and cron",{"type":171,"value":172,"toc":1819},"minimark",[173,177,187,192,261,265,276,522,541,562,566,593,759,776,780,795,810,814,821,862,873,889,897,900,904,934,943,947,1047,1060,1064,1090,1207,1213,1224,1506,1522,1525,1690,1707,1713,1755,1759,1815],[174,175,76],"h1",{"id":176},"background-jobs",[178,179,180,181,186],"p",{},"A background job is work that runs after the response, on a Cloudflare Queue,\nand gets retried when it fails. Use one when the work calls a slow or flaky\nservice (mail, a payment provider, an AI model) and must not be lost while that\nservice is down. Cron-driven cleanup lives in ",[182,183,185],"a",{"href":184},"#scheduled-tasks","scheduled tasks","\nat the end of this page.",[188,189,191],"h2",{"id":190},"how-it-works","How it works",[193,194,195,226,240,254],"ol",{},[196,197,198,199,209,210,213,214,221,222,225],"li",{},"Each file in ",[182,200,205],{"href":201,"rel":202,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Ftree\u002Fmain\u002Fserver\u002Fjobs",[203],"nofollow","Licensees only",[206,207,208],"code",{},"server\u002Fjobs\u002F"," is a job, and its file name\nis the job's ",[206,211,212],{},"kind",". At build time\n",[182,215,218],{"href":216,"rel":217,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fmodules\u002Fjobs-registry.ts",[203],[206,219,220],{},"modules\u002Fjobs-registry.ts"," turns the\ndirectory into a dispatch table and a ",[206,223,224],{},"QueueJob"," type.",[196,227,228,231,232,235,236,239],{},[206,229,230],{},"enqueue(event, { kind, payload })"," sends the job to the ",[206,233,234],{},"nuxter-jobs","\nqueue through the ",[206,237,238],{},"QUEUE"," binding.",[196,241,242,243,250,251,253],{},"The same Worker consumes that queue.\n",[182,244,247],{"href":245,"rel":246,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fplugins\u002Fqueue-consumer.ts",[203],[206,248,249],{},"server\u002Fplugins\u002Fqueue-consumer.ts","\nfinds the handler by ",[206,252,212],{}," and runs it. A handler that returns is done.\nOne that throws is retried with a growing delay.",[196,255,256,257,260],{},"After the last retry, Cloudflare moves the message to\n",[206,258,259],{},"nuxter-jobs-dlq",". The Worker reports each message there as an error\nand deletes it without running it.",[188,262,264],{"id":263},"add-a-job","Add a job",[178,266,267,268,271,272,275],{},"Create ",[206,269,270],{},"server\u002Fjobs\u002Fcrm-sync.ts"," and default-export ",[206,273,274],{},"defineJob",":",[277,278,283],"pre",{"className":279,"code":280,"language":281,"meta":282,"style":282},"language-ts shiki shiki-themes github-light github-light github-dark","import { defineJob } from '#server\u002Futils\u002Fjobs'\n\nexport interface Payload {\n  userId: number\n}\n\nexport default defineJob\u003CPayload>({\n  async run({ userId }, event) {\n    const [user] = await useDrizzle(event)\n      .select({ email: tables.users.email })\n      .from(tables.users)\n      .where(eq(tables.users.id, userId))\n\n    if (!user) return\n\n    await $fetch('https:\u002F\u002Fcrm.example.com\u002Fcontacts', {\n      method: 'POST',\n      body: { email: user.email },\n    })\n  },\n})\n","ts","",[206,284,285,304,310,325,337,342,346,365,388,414,425,434,450,454,471,475,491,502,507,512,517],{"__ignoreMap":282},[286,287,289,293,297,300],"span",{"class":288,"line":12},"line",[286,290,292],{"class":291},"s8jYJ","import",[286,294,296],{"class":295},"sxrX7"," { defineJob } ",[286,298,299],{"class":291},"from",[286,301,303],{"class":302},"sIIMD"," '#server\u002Futils\u002Fjobs'\n",[286,305,306],{"class":288,"line":17},[286,307,309],{"emptyLinePlaceholder":308},true,"\n",[286,311,312,315,318,322],{"class":288,"line":22},[286,313,314],{"class":291},"export",[286,316,317],{"class":291}," interface",[286,319,321],{"class":320},"snPdu"," Payload",[286,323,324],{"class":295}," {\n",[286,326,327,331,333],{"class":288,"line":27},[286,328,330],{"class":329},"sP4rz","  userId",[286,332,275],{"class":291},[286,334,336],{"class":335},"sBjJW"," number\n",[286,338,339],{"class":288,"line":32},[286,340,341],{"class":295},"}\n",[286,343,344],{"class":288,"line":37},[286,345,309],{"emptyLinePlaceholder":308},[286,347,348,350,353,356,359,362],{"class":288,"line":46},[286,349,314],{"class":291},[286,351,352],{"class":291}," default",[286,354,355],{"class":320}," defineJob",[286,357,358],{"class":295},"\u003C",[286,360,361],{"class":320},"Payload",[286,363,364],{"class":295},">({\n",[286,366,367,370,373,376,379,382,385],{"class":288,"line":51},[286,368,369],{"class":291},"  async",[286,371,372],{"class":320}," run",[286,374,375],{"class":295},"({ ",[286,377,378],{"class":329},"userId",[286,380,381],{"class":295}," }, ",[286,383,384],{"class":329},"event",[286,386,387],{"class":295},") {\n",[286,389,390,393,396,399,402,405,408,411],{"class":288,"line":56},[286,391,392],{"class":291},"    const",[286,394,395],{"class":295}," [",[286,397,398],{"class":335},"user",[286,400,401],{"class":295},"] ",[286,403,404],{"class":291},"=",[286,406,407],{"class":291}," await",[286,409,410],{"class":320}," useDrizzle",[286,412,413],{"class":295},"(event)\n",[286,415,416,419,422],{"class":288,"line":64},[286,417,418],{"class":295},"      .",[286,420,421],{"class":320},"select",[286,423,424],{"class":295},"({ email: tables.users.email })\n",[286,426,427,429,431],{"class":288,"line":73},[286,428,418],{"class":295},[286,430,299],{"class":320},[286,432,433],{"class":295},"(tables.users)\n",[286,435,436,438,441,444,447],{"class":288,"line":78},[286,437,418],{"class":295},[286,439,440],{"class":320},"where",[286,442,443],{"class":295},"(",[286,445,446],{"class":320},"eq",[286,448,449],{"class":295},"(tables.users.id, userId))\n",[286,451,452],{"class":288,"line":83},[286,453,309],{"emptyLinePlaceholder":308},[286,455,456,459,462,465,468],{"class":288,"line":88},[286,457,458],{"class":291},"    if",[286,460,461],{"class":295}," (",[286,463,464],{"class":291},"!",[286,466,467],{"class":295},"user) ",[286,469,470],{"class":291},"return\n",[286,472,473],{"class":288,"line":93},[286,474,309],{"emptyLinePlaceholder":308},[286,476,477,480,483,485,488],{"class":288,"line":98},[286,478,479],{"class":291},"    await",[286,481,482],{"class":320}," $fetch",[286,484,443],{"class":295},[286,486,487],{"class":302},"'https:\u002F\u002Fcrm.example.com\u002Fcontacts'",[286,489,490],{"class":295},", {\n",[286,492,493,496,499],{"class":288,"line":103},[286,494,495],{"class":295},"      method: ",[286,497,498],{"class":302},"'POST'",[286,500,501],{"class":295},",\n",[286,503,504],{"class":288,"line":108},[286,505,506],{"class":295},"      body: { email: user.email },\n",[286,508,509],{"class":288,"line":113},[286,510,511],{"class":295},"    })\n",[286,513,514],{"class":288,"line":122},[286,515,516],{"class":295},"  },\n",[286,518,519],{"class":288,"line":127},[286,520,521],{"class":295},"})\n",[178,523,524,525,528,529,532,533,536,537,540],{},"Nothing else needs editing. The kind is ",[206,526,527],{},"crm-sync",", and ",[206,530,531],{},"enqueue"," takes its\npayload type from the ",[206,534,535],{},"run"," parameter, so a wrong kind or payload shape fails\ntype checking at the call site. File names must be lowercase kebab-case, or the\nbuild fails. The registry is generated when Nuxt starts, so restart ",[206,538,539],{},"pnpm dev","\nafter you add, rename or delete a job file.",[178,542,543,544,546,547,549,550,553,554,557,558,561],{},"The ",[206,545,384],{}," passed to ",[206,548,535],{}," holds the Cloudflare bindings and nothing else.\n",[206,551,552],{},"useDrizzle(event)",", ",[206,555,556],{},"useRuntimeConfig(event)"," and\n",[206,559,560],{},"event.context.cloudflare.env"," work, but there are no headers, cookies or\nsession. Copy what the job needs from the request into the payload.",[188,563,565],{"id":564},"enqueue-a-job","Enqueue a job",[178,567,568,570,571,578,579,582,583,586,587,589,590,592],{},[206,569,531],{}," lives in ",[182,572,575],{"href":573,"rel":574,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Futils\u002Fqueue.ts",[203],[206,576,577],{},"server\u002Futils\u002Fqueue.ts",". It\nreturns ",[206,580,581],{},"true"," when the queue accepts the job and ",[206,584,585],{},"false"," when the queue is\noff, the event has no ",[206,588,238],{}," binding, or the send failed. It never throws, so\nthe caller decides what ",[206,591,585],{}," means:",[277,594,596],{"className":279,"code":595,"language":281,"meta":282,"style":282},"import { enqueue } from '#server\u002Futils\u002Fqueue'\n\nexport default defineEventHandler(async (event) => {\n  const session = await requireVerifiedSession(event)\n\n  const queued = await enqueue(event, {\n    kind: 'crm-sync',\n    payload: { userId: session.user.id },\n  })\n\n  if (!queued) {\n    throw createError({ statusCode: 503, statusMessage: 'Try again later.' })\n  }\n\n  return { queued }\n})\n",[206,597,598,610,614,640,658,662,679,689,694,699,703,715,738,743,747,755],{"__ignoreMap":282},[286,599,600,602,605,607],{"class":288,"line":12},[286,601,292],{"class":291},[286,603,604],{"class":295}," { enqueue } ",[286,606,299],{"class":291},[286,608,609],{"class":302}," '#server\u002Futils\u002Fqueue'\n",[286,611,612],{"class":288,"line":17},[286,613,309],{"emptyLinePlaceholder":308},[286,615,616,618,620,623,625,628,630,632,635,638],{"class":288,"line":22},[286,617,314],{"class":291},[286,619,352],{"class":291},[286,621,622],{"class":320}," defineEventHandler",[286,624,443],{"class":295},[286,626,627],{"class":291},"async",[286,629,461],{"class":295},[286,631,384],{"class":329},[286,633,634],{"class":295},") ",[286,636,637],{"class":291},"=>",[286,639,324],{"class":295},[286,641,642,645,648,651,653,656],{"class":288,"line":27},[286,643,644],{"class":291},"  const",[286,646,647],{"class":335}," session",[286,649,650],{"class":291}," =",[286,652,407],{"class":291},[286,654,655],{"class":320}," requireVerifiedSession",[286,657,413],{"class":295},[286,659,660],{"class":288,"line":32},[286,661,309],{"emptyLinePlaceholder":308},[286,663,664,666,669,671,673,676],{"class":288,"line":37},[286,665,644],{"class":291},[286,667,668],{"class":335}," queued",[286,670,650],{"class":291},[286,672,407],{"class":291},[286,674,675],{"class":320}," enqueue",[286,677,678],{"class":295},"(event, {\n",[286,680,681,684,687],{"class":288,"line":46},[286,682,683],{"class":295},"    kind: ",[286,685,686],{"class":302},"'crm-sync'",[286,688,501],{"class":295},[286,690,691],{"class":288,"line":51},[286,692,693],{"class":295},"    payload: { userId: session.user.id },\n",[286,695,696],{"class":288,"line":56},[286,697,698],{"class":295},"  })\n",[286,700,701],{"class":288,"line":64},[286,702,309],{"emptyLinePlaceholder":308},[286,704,705,708,710,712],{"class":288,"line":73},[286,706,707],{"class":291},"  if",[286,709,461],{"class":295},[286,711,464],{"class":291},[286,713,714],{"class":295},"queued) {\n",[286,716,717,720,723,726,729,732,735],{"class":288,"line":78},[286,718,719],{"class":291},"    throw",[286,721,722],{"class":320}," createError",[286,724,725],{"class":295},"({ statusCode: ",[286,727,728],{"class":335},"503",[286,730,731],{"class":295},", statusMessage: ",[286,733,734],{"class":302},"'Try again later.'",[286,736,737],{"class":295}," })\n",[286,739,740],{"class":288,"line":83},[286,741,742],{"class":295},"  }\n",[286,744,745],{"class":288,"line":88},[286,746,309],{"emptyLinePlaceholder":308},[286,748,749,752],{"class":288,"line":93},[286,750,751],{"class":291},"  return",[286,753,754],{"class":295}," { queued }\n",[286,756,757],{"class":288,"line":98},[286,758,521],{"class":295},[178,760,761,762,764,765,772,773,775],{},"The shipped listeners answer ",[206,763,585],{}," by running the same work inline, as\n",[182,766,769],{"href":767,"rel":768,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Flisteners\u002F00-billing-email.ts",[203],[206,770,771],{},"server\u002Flisteners\u002F00-billing-email.ts","\ndoes. A listener's own work is never retried, so enqueue from it when the work\nhas to survive a failure (",[182,774,81],{"href":80},").",[188,777,779],{"id":778},"run-jobs-locally","Run jobs locally",[178,781,782,784,785,787,788,791,792,794],{},[206,783,539],{}," has no queue consumer, so ",[206,786,531],{}," starts the handler in the same\nprocess without awaiting it, logs a ",[206,789,790],{},"[queue]"," warning naming the kind, and\nreturns ",[206,793,581],{},". Two things differ from production: a throw is logged and\ndropped instead of retried, and the payload is passed as-is instead of going\nthrough the queue.",[178,796,797,800,801,804,805,807,808,775],{},[206,798,799],{},"pnpm preview"," builds the Worker and runs it under ",[206,802,803],{},"wrangler dev"," with the real\nqueue, retries and dead letter queue. It needs a real Postgres, not the PGlite\nthat ",[206,806,539],{}," uses (",[182,809,71],{"href":70},[188,811,813],{"id":812},"change-the-retry-policy","Change the retry policy",[178,815,816,817,820],{},"The consumer waits one minute after the first failure and doubles the wait each\ntime. With the shipped ",[206,818,819],{},"max_retries: 5",", a job gets six attempts over about 31\nminutes (waits of 1, 2, 4, 8 and 16 minutes) before it dead-letters.",[822,823,824,842],"ul",{},[196,825,826,827,830,831,833,834,841],{},"The number of retries is ",[206,828,829],{},"max_retries"," on the ",[206,832,234],{}," consumer in\n",[182,835,838],{"href":836,"rel":837,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fwrangler.jsonc",[203],[206,839,840],{},"wrangler.jsonc",".",[196,843,844,845,848,849,854,855,858,859,861],{},"The spacing is ",[206,846,847],{},"retryDelaySeconds()"," in\n",[182,850,852],{"href":245,"rel":851,"title":204},[203],[206,853,249],{},",\ncapped at 12 hours. A ",[206,856,857],{},"retry_delay"," in ",[206,860,840],{}," has no effect,\nbecause the consumer passes its own delay on every retry.",[178,863,864,865,872],{},"Every job shares this policy. A job that shouldn't retry a failure catches it\nand returns, as ",[182,866,869],{"href":867,"rel":868,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Fjobs\u002Femail.ts",[203],[206,870,871],{},"server\u002Fjobs\u002Femail.ts"," does when\nevery provider refuses a message permanently.",[178,874,875,876,879,880,882,883,557,886,275],{},"A dead job goes to the ",[206,877,878],{},"error"," hook, which logs it and forwards it to your\nerror trackers (",[182,881,135],{"href":134},") tagged ",[206,884,885],{},"queue",[206,887,888],{},"dead-letter",[277,890,895],{"className":891,"code":893,"language":894,"meta":282},[892],"language-text","[queue] a \"crm-sync\" job died after 6 attempts: message \u003Cid> on \"nuxter-jobs-dlq\", dead letter delivery 1; acknowledged without running\n","text",[206,896,893],{"__ignoreMap":282},[178,898,899],{},"The report leaves out the payload, and the message is deleted right after, so\na dead job can't be replayed. A kind with no handler is retried like any\nfailure, which carries a new job across a deploy while the old Worker is still\nconsuming. Messages for a job file you deleted end up in the dead letter queue.",[188,901,903],{"id":902},"turn-the-queue-off","Turn the queue off",[178,905,906,907,858,910,917,918,920,921,923,924,927,928,930,931,933],{},"Set ",[206,908,909],{},"queue.enabled: false",[182,911,914],{"href":912,"rel":913,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fstarter.config.ts",[203],[206,915,916],{},"starter.config.ts",".\n",[206,919,531],{}," then returns ",[206,922,585],{}," everywhere and the shipped callers fall back: a\nmail every provider refuses returns a 502 instead of being retried later, and\nthe billing-email sync, invitation mail and subscription cancel run inside\ntheir listeners. The build skips the ",[206,925,926],{},"queues"," checks and bundles no job\nhandlers. ",[206,929,224],{}," still lists every job, so your ",[206,932,531],{}," calls still\ntype-check.",[178,935,936,937,939,940,942],{},"Wrangler still binds whatever the ",[206,938,926],{}," block in ",[206,941,840],{}," lists, so\neither create the queues anyway or remove that block.",[188,944,946],{"id":945},"shipped-jobs","Shipped jobs",[948,949,950,966],"table",{},[951,952,953],"thead",{},[954,955,956,960,963],"tr",{},[957,958,959],"th",{},"Kind",[957,961,962],{},"What it does",[957,964,965],{},"Enqueued by",[967,968,969,992,1011,1029],"tbody",{},[954,970,971,977,980],{},[972,973,974],"td",{},[206,975,976],{},"email",[972,978,979],{},"Resends a rendered mail after a transient failure",[972,981,982,858,985],{},[206,983,984],{},"deliverEmail",[182,986,989],{"href":987,"rel":988,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fserver\u002Femail\u002Findex.ts",[203],[206,990,991],{},"server\u002Femail\u002Findex.ts",[954,993,994,999,1002],{},[972,995,996],{},[206,997,998],{},"billing-email",[972,1000,1001],{},"Moves the user's payment-provider customers to a new email",[972,1003,1004,1007,1008],{},[206,1005,1006],{},"00-billing-email.ts"," on ",[206,1009,1010],{},"auth:emailChanged",[954,1012,1013,1018,1021],{},[972,1014,1015],{},[206,1016,1017],{},"cancel-billing",[972,1019,1020],{},"Cancels provider subscriptions of a deleted account",[972,1022,1023,1007,1026],{},[206,1024,1025],{},"00-cancel-billing.ts",[206,1027,1028],{},"account:deletionScheduled",[954,1030,1031,1036,1039],{},[972,1032,1033],{},[206,1034,1035],{},"org-invitation-email",[972,1037,1038],{},"Sends an organization invitation",[972,1040,1041,1007,1044],{},[206,1042,1043],{},"00-org-invitation-mail.ts",[206,1045,1046],{},"org:invitation:created",[178,1048,1049,1050,1052,1053,1056,1057,775],{},"The template owns these four files. Any other file in ",[206,1051,208],{}," is yours,\nand ",[206,1054,1055],{},"pnpm template:update"," leaves it alone (",[182,1058,1059],{"href":24},"Template",[188,1061,1063],{"id":1062},"scheduled-tasks","Scheduled tasks",[178,1065,1066,1067,1074,1075,1077,1078,1081,1082,1089],{},"Scheduled tasks don't use the queue. They are Nitro tasks in\n",[182,1068,1071],{"href":1069,"rel":1070,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Ftree\u002Fmain\u002Fserver\u002Ftasks",[203],[206,1072,1073],{},"server\u002Ftasks\u002F",", run by the cron in ",[206,1076,840],{},"\n(",[206,1079,1080],{},"0 3 * * *",", daily at 03:00 UTC). ",[182,1083,1086],{"href":1084,"rel":1085,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Fnuxt.config.ts",[203],[206,1087,1088],{},"nuxt.config.ts","\nregisters every enabled task against every cron listed there.",[948,1091,1092,1105],{},[951,1093,1094],{},[954,1095,1096,1099,1102],{},[957,1097,1098],{},"Task",[957,1100,1101],{},"Deletes",[957,1103,1104],{},"Runs when",[967,1106,1107,1127,1142,1156,1177,1192],{},[954,1108,1109,1114,1120],{},[972,1110,1111],{},[206,1112,1113],{},"activity:prune",[972,1115,1116,1117],{},"Activity entries older than ",[206,1118,1119],{},"activity.retentionDays",[972,1121,1122,1124,1125],{},[206,1123,1119],{}," is not ",[206,1126,585],{},[954,1128,1129,1134,1137],{},[972,1130,1131],{},[206,1132,1133],{},"sessions:prune",[972,1135,1136],{},"Revoked and idle sessions",[972,1138,1139],{},[206,1140,1141],{},"auth.enabled",[954,1143,1144,1149,1152],{},[972,1145,1146],{},[206,1147,1148],{},"account:purge",[972,1150,1151],{},"Accounts whose deletion grace period has ended",[972,1153,1154],{},[206,1155,1141],{},[954,1157,1158,1163,1169],{},[972,1159,1160],{},[206,1161,1162],{},"notifications:prune",[972,1164,1165,1166],{},"Read notifications older than ",[206,1167,1168],{},"notifications.retentionDays",[972,1170,1171,1174,1175],{},[206,1172,1173],{},"notifications.enabled"," and retention is not ",[206,1176,585],{},[954,1178,1179,1184,1187],{},[972,1180,1181],{},[206,1182,1183],{},"invitations:prune",[972,1185,1186],{},"Invitations 30 days past their expiry",[972,1188,1189],{},[206,1190,1191],{},"orgs.enabled",[954,1193,1194,1199,1202],{},[972,1195,1196],{},[206,1197,1198],{},"checkouts:prune",[972,1200,1201],{},"Expired open-checkout rows",[972,1203,1204],{},[206,1205,1206],{},"payments.enabled",[178,1208,1209,1210,841],{},"Prunes delete 1,000 rows at a time and stop after 20 chunks per run, shared\nacross all of them. If your tables gain more than 20,000 prunable rows a day,\nrun the cron more often, for example ",[206,1211,1212],{},"0 * * * *",[178,1214,1215,1216,1219,1220,1223],{},"To add a task, create ",[206,1217,1218],{},"server\u002Ftasks\u002Freports\u002Fsync.ts",", which Nitro names\n",[206,1221,1222],{},"reports:sync",". A task gets no event, so build one from its context:",[277,1225,1227],{"className":279,"code":1226,"language":281,"meta":282,"style":282},"import {\n  fabricatedEvent,\n  taskCloudflareBinding,\n} from '#server\u002Fevents\u002Ffabricated-event'\nimport { defineReportedTask } from '#server\u002Fmonitoring\u002Ftask'\nimport { enqueue } from '#server\u002Futils\u002Fqueue'\n\nexport default defineReportedTask({\n  meta: { name: 'reports:sync', description: 'Queue a CRM sync per user' },\n\n  async run({ context }) {\n    const binding = taskCloudflareBinding(context)\n\n    if (!binding) throw new Error('reports:sync has no Cloudflare bindings')\n\n    const event = fabricatedEvent({ cloudflare: binding })\n\n    const users = await useDrizzle(event)\n      .select({ id: tables.users.id })\n      .from(tables.users)\n\n    for (const { id } of users) {\n      await enqueue(event, { kind: 'crm-sync', payload: { userId: id } })\n    }\n\n    return { result: { queued: users.length } }\n  },\n})\n",[206,1228,1229,1235,1240,1245,1255,1267,1277,1281,1293,1310,1314,1328,1343,1347,1375,1379,1394,1398,1413,1422,1430,1434,1459,1474,1479,1483,1497,1501],{"__ignoreMap":282},[286,1230,1231,1233],{"class":288,"line":12},[286,1232,292],{"class":291},[286,1234,324],{"class":295},[286,1236,1237],{"class":288,"line":17},[286,1238,1239],{"class":295},"  fabricatedEvent,\n",[286,1241,1242],{"class":288,"line":22},[286,1243,1244],{"class":295},"  taskCloudflareBinding,\n",[286,1246,1247,1250,1252],{"class":288,"line":27},[286,1248,1249],{"class":295},"} ",[286,1251,299],{"class":291},[286,1253,1254],{"class":302}," '#server\u002Fevents\u002Ffabricated-event'\n",[286,1256,1257,1259,1262,1264],{"class":288,"line":32},[286,1258,292],{"class":291},[286,1260,1261],{"class":295}," { defineReportedTask } ",[286,1263,299],{"class":291},[286,1265,1266],{"class":302}," '#server\u002Fmonitoring\u002Ftask'\n",[286,1268,1269,1271,1273,1275],{"class":288,"line":37},[286,1270,292],{"class":291},[286,1272,604],{"class":295},[286,1274,299],{"class":291},[286,1276,609],{"class":302},[286,1278,1279],{"class":288,"line":46},[286,1280,309],{"emptyLinePlaceholder":308},[286,1282,1283,1285,1287,1290],{"class":288,"line":51},[286,1284,314],{"class":291},[286,1286,352],{"class":291},[286,1288,1289],{"class":320}," defineReportedTask",[286,1291,1292],{"class":295},"({\n",[286,1294,1295,1298,1301,1304,1307],{"class":288,"line":56},[286,1296,1297],{"class":295},"  meta: { name: ",[286,1299,1300],{"class":302},"'reports:sync'",[286,1302,1303],{"class":295},", description: ",[286,1305,1306],{"class":302},"'Queue a CRM sync per user'",[286,1308,1309],{"class":295}," },\n",[286,1311,1312],{"class":288,"line":64},[286,1313,309],{"emptyLinePlaceholder":308},[286,1315,1316,1318,1320,1322,1325],{"class":288,"line":73},[286,1317,369],{"class":291},[286,1319,372],{"class":320},[286,1321,375],{"class":295},[286,1323,1324],{"class":329},"context",[286,1326,1327],{"class":295}," }) {\n",[286,1329,1330,1332,1335,1337,1340],{"class":288,"line":78},[286,1331,392],{"class":291},[286,1333,1334],{"class":335}," binding",[286,1336,650],{"class":291},[286,1338,1339],{"class":320}," taskCloudflareBinding",[286,1341,1342],{"class":295},"(context)\n",[286,1344,1345],{"class":288,"line":83},[286,1346,309],{"emptyLinePlaceholder":308},[286,1348,1349,1351,1353,1355,1358,1361,1364,1367,1369,1372],{"class":288,"line":88},[286,1350,458],{"class":291},[286,1352,461],{"class":295},[286,1354,464],{"class":291},[286,1356,1357],{"class":295},"binding) ",[286,1359,1360],{"class":291},"throw",[286,1362,1363],{"class":291}," new",[286,1365,1366],{"class":320}," Error",[286,1368,443],{"class":295},[286,1370,1371],{"class":302},"'reports:sync has no Cloudflare bindings'",[286,1373,1374],{"class":295},")\n",[286,1376,1377],{"class":288,"line":93},[286,1378,309],{"emptyLinePlaceholder":308},[286,1380,1381,1383,1386,1388,1391],{"class":288,"line":98},[286,1382,392],{"class":291},[286,1384,1385],{"class":335}," event",[286,1387,650],{"class":291},[286,1389,1390],{"class":320}," fabricatedEvent",[286,1392,1393],{"class":295},"({ cloudflare: binding })\n",[286,1395,1396],{"class":288,"line":103},[286,1397,309],{"emptyLinePlaceholder":308},[286,1399,1400,1402,1405,1407,1409,1411],{"class":288,"line":108},[286,1401,392],{"class":291},[286,1403,1404],{"class":335}," users",[286,1406,650],{"class":291},[286,1408,407],{"class":291},[286,1410,410],{"class":320},[286,1412,413],{"class":295},[286,1414,1415,1417,1419],{"class":288,"line":113},[286,1416,418],{"class":295},[286,1418,421],{"class":320},[286,1420,1421],{"class":295},"({ id: tables.users.id })\n",[286,1423,1424,1426,1428],{"class":288,"line":122},[286,1425,418],{"class":295},[286,1427,299],{"class":320},[286,1429,433],{"class":295},[286,1431,1432],{"class":288,"line":127},[286,1433,309],{"emptyLinePlaceholder":308},[286,1435,1436,1439,1441,1444,1447,1450,1453,1456],{"class":288,"line":132},[286,1437,1438],{"class":291},"    for",[286,1440,461],{"class":295},[286,1442,1443],{"class":291},"const",[286,1445,1446],{"class":295}," { ",[286,1448,1449],{"class":335},"id",[286,1451,1452],{"class":295}," } ",[286,1454,1455],{"class":291},"of",[286,1457,1458],{"class":295}," users) {\n",[286,1460,1461,1464,1466,1469,1471],{"class":288,"line":137},[286,1462,1463],{"class":291},"      await",[286,1465,675],{"class":320},[286,1467,1468],{"class":295},"(event, { kind: ",[286,1470,686],{"class":302},[286,1472,1473],{"class":295},", payload: { userId: id } })\n",[286,1475,1476],{"class":288,"line":146},[286,1477,1478],{"class":295},"    }\n",[286,1480,1481],{"class":288,"line":151},[286,1482,309],{"emptyLinePlaceholder":308},[286,1484,1485,1488,1491,1494],{"class":288,"line":156},[286,1486,1487],{"class":291},"    return",[286,1489,1490],{"class":295}," { result: { queued: users.",[286,1492,1493],{"class":335},"length",[286,1495,1496],{"class":295}," } }\n",[286,1498,1499],{"class":288,"line":165},[286,1500,516],{"class":295},[286,1502,1504],{"class":288,"line":1503},28,[286,1505,521],{"class":295},[178,1507,1508,1511,1512,1514,1515,1518,1519,1521],{},[206,1509,1510],{},"defineReportedTask"," sends a throw to your error trackers, so throw when the\nbindings are missing rather than returning quietly. Then add ",[206,1513,1300],{},"\nto the ",[206,1516,1517],{},"nitro.scheduledTasks"," list in ",[206,1520,1088],{},". That file belongs to\nthe template, so expect a small conflict there on updates.",[188,1523,20],{"id":1524},"configuration",[948,1526,1527,1542],{},[951,1528,1529],{},[954,1530,1531,1534,1537,1540],{},[957,1532,1533],{},"Key",[957,1535,1536],{},"Where",[957,1538,1539],{},"Default",[957,1541,962],{},[967,1543,1544,1567,1591,1610,1635,1653,1671],{},[954,1545,1546,1551,1555,1559],{},[972,1547,1548],{},[206,1549,1550],{},"queue.enabled",[972,1552,1553],{},[206,1554,916],{},[972,1556,1557],{},[206,1558,581],{},[972,1560,1561,1562,1564,1565,841],{},"Turns the queue on. Off, ",[206,1563,531],{}," returns ",[206,1566,585],{},[954,1568,1569,1577,1581,1585],{},[972,1570,1571,1574,1575],{},[206,1572,1573],{},"queues.producers"," bound as ",[206,1576,238],{},[972,1578,1579],{},[206,1580,840],{},[972,1582,1583],{},[206,1584,234],{},[972,1586,1587,1588,1590],{},"The queue ",[206,1589,531],{}," sends to.",[954,1592,1593,1598,1602,1607],{},[972,1594,1595,1597],{},[206,1596,829],{}," on the jobs consumer",[972,1599,1600],{},[206,1601,840],{},[972,1603,1604],{},[206,1605,1606],{},"5",[972,1608,1609],{},"Retries before a job dead-letters.",[954,1611,1612,1620,1624,1632],{},[972,1613,1614,553,1617,1597],{},[206,1615,1616],{},"max_batch_size",[206,1618,1619],{},"max_batch_timeout",[972,1621,1622],{},[206,1623,840],{},[972,1625,1626,553,1629,1631],{},[206,1627,1628],{},"10",[206,1630,1606],{}," seconds",[972,1633,1634],{},"Messages per consumer run, and how long Cloudflare waits to fill a batch.",[954,1636,1637,1642,1646,1650],{},[972,1638,1639,1597],{},[206,1640,1641],{},"dead_letter_queue",[972,1643,1644],{},[206,1645,840],{},[972,1647,1648],{},[206,1649,259],{},[972,1651,1652],{},"Where exhausted jobs go.",[954,1654,1655,1658,1662,1668],{},[972,1656,1657],{},"Consumer for the dead letter queue",[972,1659,1660],{},[206,1661,840],{},[972,1663,1664,1665],{},"batches of ",[206,1666,1667],{},"100",[972,1669,1670],{},"Reports and deletes dead jobs.",[954,1672,1673,1678,1682,1687],{},[972,1674,1675],{},[206,1676,1677],{},"triggers.crons",[972,1679,1680],{},[206,1681,840],{},[972,1683,1684],{},[206,1685,1686],{},"[\"0 3 * * *\"]",[972,1688,1689],{},"When scheduled tasks run.",[178,1691,1692,1693,1695,1696,1698,1699,1701,1702,1704,1705,841],{},"There are no environment variables. The build reads the queue names from\n",[206,1694,840],{},", so a rename happens there and nowhere else. With the queue\non, the build fails unless ",[206,1697,840],{}," has the ",[206,1700,238],{}," producer, a\nconsumer for that queue naming a ",[206,1703,1641],{},", and a consumer for the\ndead letter queue. It also refuses a dead letter queue that is the jobs queue\nor names one of its own, and an empty ",[206,1706,208],{},[178,1708,1709,1710,1712],{},"Create both queues before the first deploy (",[182,1711,30],{"href":29},"):",[277,1714,1718],{"className":1715,"code":1716,"language":1717,"meta":282,"style":282},"language-bash shiki shiki-themes github-light github-light github-dark","pnpm exec wrangler queues create nuxter-jobs\npnpm exec wrangler queues create nuxter-jobs-dlq\n","bash",[206,1719,1720,1740],{"__ignoreMap":282},[286,1721,1722,1725,1728,1731,1734,1737],{"class":288,"line":12},[286,1723,1724],{"class":320},"pnpm",[286,1726,1727],{"class":302}," exec",[286,1729,1730],{"class":302}," wrangler",[286,1732,1733],{"class":302}," queues",[286,1735,1736],{"class":302}," create",[286,1738,1739],{"class":302}," nuxter-jobs\n",[286,1741,1742,1744,1746,1748,1750,1752],{"class":288,"line":17},[286,1743,1724],{"class":320},[286,1745,1727],{"class":302},[286,1747,1730],{"class":302},[286,1749,1733],{"class":302},[286,1751,1736],{"class":302},[286,1753,1754],{"class":302}," nuxter-jobs-dlq\n",[188,1756,1758],{"id":1757},"good-to-know","Good to know",[822,1760,1761,1764,1767,1773,1794],{},[196,1762,1763],{},"A handler can run more than once, and a retry reruns all of it. Make it safe\nto repeat. For work billed per attempt, keep a status row and check it first.",[196,1765,1766],{},"Throw only when a retry can help. A failure that will repeat the same way,\nlike bad input or a permanent refusal, should be logged and returned, or it\nburns six attempts and dead-letters.",[196,1768,1769,1770,1772],{},"Keep payloads to plain JSON: ids, strings, numbers. A queue message is capped\nat 128 KB, so store files in R2 (",[182,1771,101],{"href":100},") and pass the key.",[196,1774,1775,1777,1778,1780,1781,1784,1785,1788,1789,1791,1792,841],{},[206,1776,531],{}," reads ",[206,1779,238],{}," off the event. A request's event, a listener's event\nand ",[206,1782,1783],{},"fabricatedEvent({ cloudflare })"," all carry it. An event built without\n",[206,1786,1787],{},"cloudflare"," makes ",[206,1790,531],{}," return ",[206,1793,585],{},[196,1795,1796,1797,1800,1801,1804,1805,1812,1813,775],{},"Unit tests alias ",[206,1798,1799],{},"#jobs-registry"," to a stub with no handlers. Test a job by\nimporting its default export and calling ",[206,1802,1803],{},"run(payload, event)",", as\n",[182,1806,1809],{"href":1807,"rel":1808,"title":204},"https:\u002F\u002Fgithub.com\u002Fnuxter-app\u002Fkit\u002Fblob\u002Fmain\u002Ftest\u002Funit\u002Finvitation-mail.test.ts",[203],[206,1810,1811],{},"test\u002Funit\u002Finvitation-mail.test.ts","\ndoes (",[182,1814,163],{"href":162},[1816,1817,1818],"style",{},"html pre.shiki code .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sxrX7, html code.shiki .sxrX7{--shiki-light:#24292E;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sIIMD, html code.shiki .sIIMD{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .snPdu, html code.shiki .snPdu{--shiki-light:#6F42C1;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sP4rz, html code.shiki .sP4rz{--shiki-light:#E36209;--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sBjJW, html code.shiki .sBjJW{--shiki-light:#005CC5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":282,"searchDepth":17,"depth":17,"links":1820},[1821,1822,1823,1824,1825,1826,1827,1828,1829,1830],{"id":190,"depth":17,"text":191},{"id":263,"depth":17,"text":264},{"id":564,"depth":17,"text":565},{"id":778,"depth":17,"text":779},{"id":812,"depth":17,"text":813},{"id":902,"depth":17,"text":903},{"id":945,"depth":17,"text":946},{"id":1062,"depth":17,"text":1063},{"id":1524,"depth":17,"text":20},{"id":1757,"depth":17,"text":1758},[1832,1833],{"path":70,"title":71,"description":72,"navTitle":71,"group":67,"order":73},{"path":80,"title":81,"description":82,"navTitle":81,"group":67,"order":83}]