{
  "version": 3,
  "sources": ["mutation_queue_core.js"],
  "sourcesContent": ["/*\n * static/js/mutation_queue_core.js \u2014 Pure, context-free mutation-queue helpers (SNOW-376).\n *\n * Shared by BOTH the page-side queue (static/js/mutation_queue.js, loaded\n * via a plain <script> tag) and the service worker's Background Sync\n * self-drain path (static/js/sw.js, loaded via ``importScripts``). Attached\n * to ``self`` \u2014 rather than ``window`` \u2014 because ``self`` resolves to the\n * same global object in a window context AND in a worker context; ``window``\n * does not exist in a worker at all, so the two consumers could not share a\n * ``window``-scoped module.\n *\n * Deliberately dependency-free: nothing in this file touches\n * ``window.pwaDb`` / ``window.pwaTelemetry`` / IndexedDB / fetch. Every\n * export is a pure function of its arguments so it behaves identically\n * whether it's called from a live page or from the SW's one-pass replay on\n * a real ``sync`` event fired after every tab has closed (spec \u00A77.3 \u2014 the\n * actual Background Sync case, as opposed to the page-message-bridge\n * fast path used while a tab is still open).\n *\n * Public API \u2014 attached to ``self.pwaMutationQueueCore``:\n *\n *   MAX_ATTEMPTS                Retry ceiling. A row that reaches this many\n *                                attempts without a 2xx is marked ``failed``\n *                                \u2014 no further retry.\n *   SYNC_TAG                    The single shared Background Sync\n *                                registration tag both ``mutation_queue.js``\n *                                (``registration.sync.register``) and\n *                                ``sw.js`` (the ``sync`` event listener)\n *                                key off.\n *   backoffDelayMs(attempts)    Exponential backoff schedule in\n *                                milliseconds: ``min(2**attempts, 300)`` \u2014\n *                                2/4/8/16/32/64/128/256s, capped at 300s\n *                                from attempt 9 on.\n *   classifyStatus(status, doneStatuses, error)\n *                                Classifies an HTTP response status into\n *                                ``'success' | 'permanent' | 'retry'``.\n *                                Network errors have no status code to\n *                                classify \u2014 callers treat a thrown\n *                                ``fetch()`` as ``'retry'`` directly.\n *   isRowEligible(row, now)     True when a ``queue:mutations`` row is due\n *                                to be replayed right now: not already\n *                                ``failed``, and its backoff window (if\n *                                any) has elapsed.\n *   selectDrainBatch(rows, now, batchSize)\n *                                The rows one drain pass should replay:\n *                                eligible ones FIRST, then capped at\n *                                ``batchSize``. Doing it the other way\n *                                round starves the queue (SNOW-617).\n *   nextRowState(row, outcome, now, detail)\n *                                The whole post-attempt transition, as a\n *                                pure function: what to do with the row\n *                                and what it should look like afterwards.\n *                                SNOW-1262: ``detail`` (optional) is the\n *                                response's ``{status, error}``, recorded\n *                                on a failed or rescheduled row as\n *                                ``last_status`` / ``last_error``.\n *   failureDetail(status, body)  The ``{status, error}`` a response leaves\n *                                on its row: the HTTP status and the\n *                                endpoint's JSON ``error``, bounded to\n *                                ``ERROR_MAX_LENGTH`` characters.\n *   ackRecord(row, body, now)    The ``meta:app`` record a successful replay\n *                                of a row with a ``kind`` leaves behind\n *                                (SNOW-1262), or null for a row without\n *                                one: ``{key: ACK_KEY_PREFIX + key,\n *                                value: {kind, uuid, at}}``.\n *   ackExpired(record, now)      Whether an acknowledgement is past\n *                                ``ACK_TTL_MS`` and can be pruned.\n *   ACK_KEY_PREFIX, ACK_TTL_MS, DISCARD_NOTICE_KEY, ERROR_MAX_LENGTH\n *                                The constants behind the three above,\n *                                shared so the page and the worker write\n *                                the same records.\n *\n * SNOW-617: ``selectDrainBatch`` and ``nextRowState`` were the two pieces\n * of the state machine that lived OUTSIDE this module, spelled out once in\n * ``mutation_queue.js``'s ``drain``/``_processRow`` and again by hand in\n * ``sw.js``'s ``_selfDrainMutations``. That is how the offline guard came\n * to be missing from ``drain()`` while its mirror in ``telemetry.js`` had\n * one: the transitions were not in the tested unit, so nothing compared\n * the two copies.\n *\n * See docs/mutation-queue.md for the full row shape, state machine, and\n * backoff schedule this file implements.\n */\n\n(function () {\n  'use strict';\n\n  // Retry ceiling (spec \u00A77 / ticket SNOW-376). Chosen so a persistent\n  // outage (a few hours at the 300s-capped cadence) eventually stops\n  // retrying rather than growing the queue forever.\n  var MAX_ATTEMPTS = 20;\n\n  // Single shared Background Sync tag \u2014 one tag covers every mutation,\n  // rather than a tag per operation type, per the ticket's resolved design\n  // decision.\n  var SYNC_TAG = 'mutation-queue';\n\n  // SNOW-1262: a replayed row that names its ``kind`` leaves an\n  // acknowledgement in ``meta:app`` before it is deleted, keyed on its\n  // Idempotency-Key, so a consumer holding that key (route_draw.js's\n  // draft) can tell \"it landed\" from \"it was never sent\" on a later load \u2014\n  // whether the page or the service worker did the replay. Kept a week:\n  // long enough to outlast any realistic gap between a Background Sync\n  // replay and the next time the map is opened, short enough that the\n  // store never grows.\n  var ACK_KEY_PREFIX = 'mutations.ack:';\n  var ACK_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\n  // SNOW-1262: written by the worker's principal guard when it discards a\n  // row with a ``kind`` \u2014 a worker has no page to tell \u2014 and read (then\n  // deleted) by the page on its next load.\n  var DISCARD_NOTICE_KEY = 'mutations.discard-notice';\n\n  // The longest endpoint ``error`` string a row keeps. Every first-party\n  // endpoint answers a short machine code or one sentence; the bound only\n  // stops an unexpected body from bloating a row that may sit for days.\n  var ERROR_MAX_LENGTH = 200;\n\n  // SNOW-1262: core/idempotency.py's two 409s \u2014 see classifyStatus.\n  var IN_FLIGHT_ERROR = 'idempotency_in_flight';\n  var KEY_REUSED_ERROR = 'idempotency_key_reused';\n\n  /**\n   * Exponential backoff schedule for a given attempt count.\n   *\n   * attempts=1 \u2192 2s, 2 \u2192 4s, 3 \u2192 8s, 4 \u2192 16s, 5 \u2192 32s, 6 \u2192 64s, 7 \u2192 128s,\n   * 8 \u2192 256s, 9+ \u2192 capped at 300s (2**9 = 512 is the first to exceed it).\n   *\n   * @param {number} attempts\n   * @returns {number} delay in milliseconds\n   */\n  function backoffDelayMs(attempts) {\n    var seconds = Math.min(Math.pow(2, attempts), 300);\n    return seconds * 1000;\n  }\n\n  /**\n   * Classify an HTTP response status code for the drain loop.\n   *\n   * - 2xx                    \u2192 'success' \u2014 delete the row.\n   * - 408, 429, or any 5xx   \u2192 'retry'   \u2014 transient, back off and retry.\n   * - any other 4xx          \u2192 'permanent' \u2014 mark failed on first sight,\n   *                              no further retry (400/401/403/404/409/410/\n   *                              422/\u2026 \u2014 the request itself is wrong, not\n   *                              the connection).\n   * - 1xx / 3xx (unexpected from a mutation endpoint) \u2192 'retry', so an\n   *   unusual response doesn't silently drop a mutation.\n   *\n   * SNOW-1132: ``doneStatuses`` lists the codes that mean \"already applied\"\n   * for THIS mutation, and they classify as 'success' ahead of every rule\n   * above. A delete of a row that is already gone is the case: the\n   * downloads forget endpoint answers 404 for it, and a permanent failure\n   * there left a red badge that could never clear for an outcome that was\n   * exactly what the user asked for.\n   *\n   * SNOW-1262: ``error`` is the response's JSON ``error``. A 409 whose\n   * error is ``IN_FLIGHT_ERROR`` is core/idempotency.py saying the\n   * ORIGINAL request with this key is still inside the view \u2014 a post that\n   * timed out on the device but reached the server, or the other drain's\n   * replay of the same row. That answer is transient: the retry replays\n   * the original's response. Failing the row instead told the user it\n   * \"couldn't sync\" and offered an Edit that saved a second route.\n   *\n   * @param {number} status\n   * @param {number[]} [doneStatuses] Codes the caller declared as done.\n   * @param {?string} [error] The response's JSON ``error``, if any.\n   * @returns {'success'|'permanent'|'retry'}\n   */\n  function classifyStatus(status, doneStatuses, error) {\n    if (Array.isArray(doneStatuses) && doneStatuses.indexOf(status) !== -1) {\n      return 'success';\n    }\n    if (status >= 200 && status < 300) return 'success';\n    if (status === 408 || status === 429) return 'retry';\n    if (status === 409 && error === IN_FLIGHT_ERROR) return 'retry';\n    if (status >= 500) return 'retry';\n    if (status >= 400) return 'permanent';\n    return 'retry';\n  }\n\n  /**\n   * True when ``row`` should be replayed right now.\n   *\n   * @param {object} row\n   * @param {number} now epoch milliseconds\n   * @returns {boolean}\n   */\n  function isRowEligible(row, now) {\n    if (!row || row.status === 'failed') return false;\n    var nextAttemptAt = typeof row.next_attempt_at === 'number' ? row.next_attempt_at : 0;\n    return nextAttemptAt <= now;\n  }\n\n  /**\n   * The rows one drain pass should replay.\n   *\n   * SNOW-617: the order of these two steps is the whole point. ``drain()``\n   * used to read the oldest ``batchSize`` rows and filter THOSE for\n   * eligibility, so a permanently-failed row \u2014 which is never deleted,\n   * because the nav badge's error state is defined on its presence\n   * (docs/mutation-queue.md) \u2014 occupied a slot in every pass forever. With\n   * ``batchSize`` such rows at the head of the store, every eligible row\n   * behind them starved: the queue would sit there, badge showing a count,\n   * draining nothing, with no error and no telemetry to say why.\n   *\n   * Filtering first and capping after means failed rows cost nothing, and\n   * the cap still bounds how much one pass can do.\n   *\n   * @param {object[]} rows Every row in the store, oldest first.\n   * @param {number} now epoch milliseconds\n   * @param {number} batchSize Maximum rows to return.\n   * @returns {object[]}\n   */\n  function selectDrainBatch(rows, now, batchSize) {\n    var batch = [];\n    if (!rows) return batch;\n    for (var i = 0; i < rows.length; i++) {\n      if (!isRowEligible(rows[i], now)) continue;\n      batch.push(rows[i]);\n      if (batch.length >= batchSize) break;\n    }\n    return batch;\n  }\n\n  /**\n   * What to do with ``row`` after an attempt that produced ``outcome``.\n   *\n   * The whole post-attempt transition, as a pure function of its inputs \u2014\n   * so the state machine can be tested without IndexedDB, a network, or a\n   * service worker. Both drain paths call this; neither decides for\n   * itself.\n   *\n   * ``attempts`` is incremented on every non-success branch before the row\n   * is handed back, so it reflects the attempt that just happened rather\n   * than reading as \"never attempted\" \u2014 the distinction the\n   * ``pwa.mutation.failed_permanent`` telemetry relies on to tell\n   * \"failed on the first try\" from \"never tried\".\n   *\n   * @param {object} row The stored row, as read.\n   * @param {'success'|'permanent'|'retry'} outcome ``classifyStatus``'s\n   *   verdict, or ``'retry'`` for a thrown ``fetch()`` (a network error\n   *   has no status to classify).\n   * @param {number} now epoch milliseconds\n   * @param {{status: ?number, error: ?string}} [detail] SNOW-1262: what\n   *   the response said, from ``failureDetail``. When given it is stamped\n   *   on a failed or rescheduled row as ``last_status`` / ``last_error``,\n   *   so a reader can tell a validation refusal from the route cap, a\n   *   sign-out or a missing endpoint. Omitted, the row is unchanged in\n   *   shape \u2014 every caller from before SNOW-1262 gets what it always got.\n   * @returns {{action: 'delete'|'fail'|'reschedule', row?: object,\n   *   reason?: string}} ``delete`` \u2014 it succeeded, drop it. ``fail`` \u2014\n   *   persist ``row`` (already stamped ``status: 'failed'``) and report\n   *   ``reason``. ``reschedule`` \u2014 persist ``row`` with its new backoff.\n   */\n  function nextRowState(row, outcome, now, detail) {\n    if (outcome === 'success') return { action: 'delete' };\n\n    var attempts = (row && row.attempts ? row.attempts : 0) + 1;\n    var recorded = {};\n    if (detail) {\n      recorded.last_status = detail.status == null ? null : detail.status;\n      recorded.last_error = detail.error == null ? null : detail.error;\n    }\n\n    if (outcome === 'permanent') {\n      return {\n        action: 'fail',\n        row: Object.assign({}, row, recorded, { attempts: attempts, status: 'failed' }),\n        reason: 'permanent_4xx',\n      };\n    }\n\n    // 'retry' \u2014 {408, 429}, 5xx, or a network error.\n    if (attempts >= MAX_ATTEMPTS) {\n      return {\n        action: 'fail',\n        row: Object.assign({}, row, recorded, { attempts: attempts, status: 'failed' }),\n        reason: 'max_attempts',\n      };\n    }\n\n    return {\n      action: 'reschedule',\n      row: Object.assign({}, row, recorded, {\n        attempts: attempts,\n        status: 'retry-scheduled',\n        next_attempt_at: now + backoffDelayMs(attempts),\n      }),\n    };\n  }\n\n  /**\n   * What a response leaves on its row (SNOW-1262): its status and the\n   * endpoint's JSON ``error``, bounded. A body that is not JSON, or has no\n   * string ``error``, leaves ``error: null`` \u2014 the status alone still says\n   * which kind of refusal it was.\n   *\n   * @param {?number} status The HTTP status, or null for a network error.\n   * @param {*} body The parsed JSON body, or null.\n   * @returns {{status: ?number, error: ?string}}\n   */\n  function failureDetail(status, body) {\n    var error = body && typeof body.error === 'string' ? body.error.slice(0, ERROR_MAX_LENGTH) : null;\n    return { status: typeof status === 'number' ? status : null, error: error };\n  }\n\n  /**\n   * The acknowledgement a successful replay of ``row`` leaves in\n   * ``meta:app`` (SNOW-1262), or null when the row names no ``kind`` \u2014\n   * nothing is waiting to hear about it, so nothing is written.\n   *\n   * @param {object} row The replayed row.\n   * @param {*} body The parsed JSON response body, or null.\n   * @param {number} now epoch milliseconds\n   * @returns {?{key: string, value: {kind: string, uuid: ?string, at: number}}}\n   */\n  function ackRecord(row, body, now) {\n    if (!row || !row.kind || !row.idempotency_key) return null;\n    var uuid = body && typeof body.uuid === 'string' ? body.uuid : null;\n    return {\n      key: ACK_KEY_PREFIX + row.idempotency_key,\n      value: { kind: row.kind, uuid: uuid, at: now },\n    };\n  }\n\n  /**\n   * Whether a ``meta:app`` record is an acknowledgement past its week.\n   *\n   * @param {*} record A ``meta:app`` row.\n   * @param {number} now epoch milliseconds\n   * @returns {boolean}\n   */\n  function ackExpired(record, now) {\n    if (!record || typeof record.key !== 'string' || record.key.indexOf(ACK_KEY_PREFIX) !== 0) {\n      return false;\n    }\n    var at = record.value && typeof record.value.at === 'number' ? record.value.at : 0;\n    return now - at > ACK_TTL_MS;\n  }\n\n  self.pwaMutationQueueCore = Object.freeze({\n    MAX_ATTEMPTS: MAX_ATTEMPTS,\n    SYNC_TAG: SYNC_TAG,\n    backoffDelayMs: backoffDelayMs,\n    classifyStatus: classifyStatus,\n    isRowEligible: isRowEligible,\n    selectDrainBatch: selectDrainBatch,\n    nextRowState: nextRowState,\n    failureDetail: failureDetail,\n    ackRecord: ackRecord,\n    ackExpired: ackExpired,\n    ACK_KEY_PREFIX: ACK_KEY_PREFIX,\n    ACK_TTL_MS: ACK_TTL_MS,\n    DISCARD_NOTICE_KEY: DISCARD_NOTICE_KEY,\n    ERROR_MAX_LENGTH: ERROR_MAX_LENGTH,\n    IN_FLIGHT_ERROR: IN_FLIGHT_ERROR,\n    KEY_REUSED_ERROR: KEY_REUSED_ERROR,\n  });\n})();\n"],
  "mappings": "CAoFC,UAAY,CACX,aAKA,IAAIA,EAAe,GAKfC,EAAW,iBAUXC,EAAiB,iBACjBC,EAAa,MAAc,GAAK,IAKhCC,EAAqB,2BAKrBC,EAAmB,IAGnBC,EAAkB,wBAClBC,EAAmB,yBAWvB,SAASC,EAAeC,EAAU,CAChC,IAAIC,EAAU,KAAK,IAAI,KAAK,IAAI,EAAGD,CAAQ,EAAG,GAAG,EACjD,OAAOC,EAAU,GACnB,CAkCA,SAASC,EAAeC,EAAQC,EAAcC,EAAO,CAInD,OAHI,MAAM,QAAQD,CAAY,GAAKA,EAAa,QAAQD,CAAM,IAAM,IAGhEA,GAAU,KAAOA,EAAS,IAAY,UACtCA,IAAW,KAAOA,IAAW,KAC7BA,IAAW,KAAOE,IAAUR,GAC5BM,GAAU,IAAY,QACtBA,GAAU,IAAY,YACnB,OACT,CASA,SAASG,EAAcC,EAAKC,EAAK,CAC/B,GAAI,CAACD,GAAOA,EAAI,SAAW,SAAU,MAAO,GAC5C,IAAIE,EAAgB,OAAOF,EAAI,iBAAoB,SAAWA,EAAI,gBAAkB,EACpF,OAAOE,GAAiBD,CAC1B,CAsBA,SAASE,EAAiBC,EAAMH,EAAKI,EAAW,CAC9C,IAAIC,EAAQ,CAAC,EACb,GAAI,CAACF,EAAM,OAAOE,EAClB,QAASC,EAAI,EAAGA,EAAIH,EAAK,QAClB,EAAAL,EAAcK,EAAKG,CAAC,EAAGN,CAAG,IAC/BK,EAAM,KAAKF,EAAKG,CAAC,CAAC,EACdD,EAAM,QAAUD,IAHWE,IAC/B,CAIF,OAAOD,CACT,CAgCA,SAASE,EAAaR,EAAKS,EAASR,EAAKS,EAAQ,CAC/C,GAAID,IAAY,UAAW,MAAO,CAAE,OAAQ,QAAS,EAErD,IAAIhB,GAAYO,GAAOA,EAAI,SAAWA,EAAI,SAAW,GAAK,EACtDW,EAAW,CAAC,EAMhB,OALID,IACFC,EAAS,YAAcD,EAAO,QAAU,KAAO,KAAOA,EAAO,OAC7DC,EAAS,WAAaD,EAAO,OAAS,KAAO,KAAOA,EAAO,OAGzDD,IAAY,YACP,CACL,OAAQ,OACR,IAAK,OAAO,OAAO,CAAC,EAAGT,EAAKW,EAAU,CAAE,SAAUlB,EAAU,OAAQ,QAAS,CAAC,EAC9E,OAAQ,eACV,EAIEA,GAAYT,EACP,CACL,OAAQ,OACR,IAAK,OAAO,OAAO,CAAC,EAAGgB,EAAKW,EAAU,CAAE,SAAUlB,EAAU,OAAQ,QAAS,CAAC,EAC9E,OAAQ,cACV,EAGK,CACL,OAAQ,aACR,IAAK,OAAO,OAAO,CAAC,EAAGO,EAAKW,EAAU,CACpC,SAAUlB,EACV,OAAQ,kBACR,gBAAiBQ,EAAMT,EAAeC,CAAQ,CAChD,CAAC,CACH,CACF,CAYA,SAASmB,EAAchB,EAAQiB,EAAM,CACnC,IAAIf,EAAQe,GAAQ,OAAOA,EAAK,OAAU,SAAWA,EAAK,MAAM,MAAM,EAAGxB,CAAgB,EAAI,KAC7F,MAAO,CAAE,OAAQ,OAAOO,GAAW,SAAWA,EAAS,KAAM,MAAOE,CAAM,CAC5E,CAYA,SAASgB,EAAUd,EAAKa,EAAMZ,EAAK,CACjC,GAAI,CAACD,GAAO,CAACA,EAAI,MAAQ,CAACA,EAAI,gBAAiB,OAAO,KACtD,IAAIe,EAAOF,GAAQ,OAAOA,EAAK,MAAS,SAAWA,EAAK,KAAO,KAC/D,MAAO,CACL,IAAK3B,EAAiBc,EAAI,gBAC1B,MAAO,CAAE,KAAMA,EAAI,KAAM,KAAMe,EAAM,GAAId,CAAI,CAC/C,CACF,CASA,SAASe,EAAWC,EAAQhB,EAAK,CAC/B,GAAI,CAACgB,GAAU,OAAOA,EAAO,KAAQ,UAAYA,EAAO,IAAI,QAAQ/B,CAAc,IAAM,EACtF,MAAO,GAET,IAAIgC,EAAKD,EAAO,OAAS,OAAOA,EAAO,MAAM,IAAO,SAAWA,EAAO,MAAM,GAAK,EACjF,OAAOhB,EAAMiB,EAAK/B,CACpB,CAEA,KAAK,qBAAuB,OAAO,OAAO,CACxC,aAAcH,EACd,SAAUC,EACV,eAAgBO,EAChB,eAAgBG,EAChB,cAAeI,EACf,iBAAkBI,EAClB,aAAcK,EACd,cAAeI,EACf,UAAWE,EACX,WAAYE,EACZ,eAAgB9B,EAChB,WAAYC,EACZ,mBAAoBC,EACpB,iBAAkBC,EAClB,gBAAiBC,EACjB,iBAAkBC,CACpB,CAAC,CACH,GAAG",
  "names": ["MAX_ATTEMPTS", "SYNC_TAG", "ACK_KEY_PREFIX", "ACK_TTL_MS", "DISCARD_NOTICE_KEY", "ERROR_MAX_LENGTH", "IN_FLIGHT_ERROR", "KEY_REUSED_ERROR", "backoffDelayMs", "attempts", "seconds", "classifyStatus", "status", "doneStatuses", "error", "isRowEligible", "row", "now", "nextAttemptAt", "selectDrainBatch", "rows", "batchSize", "batch", "i", "nextRowState", "outcome", "detail", "recorded", "failureDetail", "body", "ackRecord", "uuid", "ackExpired", "record", "at"]
}
