@mushi-mushi/core 1.27.1 → 1.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,4 +138,4 @@ MIT
138
138
  <!-- mushi-readme-stats-footer -->
139
139
  ---
140
140
 
141
- <sub>Monorepo scale (July 2026): 55 edge functions · 331 SQL migrations · 13 outbound plugins · 11 inbound adapters · 19 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
141
+ <sub>Monorepo scale (July 2026): 55 edge functions · 336 SQL migrations · 13 outbound plugins · 11 inbound adapters · 19 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
package/dist/index.cjs CHANGED
@@ -111,6 +111,7 @@ var DEFAULT_TIMEOUT = 1e4;
111
111
  var DEFAULT_MAX_RETRIES = 2;
112
112
  var DEFAULT_CIRCUIT_THRESHOLD = 4;
113
113
  var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
114
+ var MAX_INLINE_RETRY_WAIT_MS = 1e4;
114
115
  function createApiClient(options) {
115
116
  const {
116
117
  projectId,
@@ -185,11 +186,15 @@ function createApiClient(options) {
185
186
  `[Mushi] Credentials rejected (HTTP ${response.status}). Check your Project ID and API key scope (must be "report:write"). ` + where
186
187
  );
187
188
  }
188
- if (response.status >= 500 && retries > 0) {
189
- await sleep(getBackoffDelay(maxRetries - retries));
189
+ const rateLimited = response.status === 429;
190
+ const unusable = response.status >= 500 || rateLimited;
191
+ const retryAfterMs = rateLimited ? parseRetryAfter(response.headers.get("Retry-After")) : null;
192
+ const canWaitInline = retryAfterMs === null || retryAfterMs <= MAX_INLINE_RETRY_WAIT_MS;
193
+ if (unusable && retries > 0 && canWaitInline) {
194
+ await sleep(retryAfterMs ?? getBackoffDelay(maxRetries - retries));
190
195
  return request(method, path, body, retries - 1, internalKind, extraHeaders);
191
196
  }
192
- if (response.status >= 500) cbRecordUnreachable();
197
+ if (unusable) cbRecordUnreachable();
193
198
  else cbRecordReachable();
194
199
  return {
195
200
  ok: false,
@@ -207,7 +212,7 @@ function createApiClient(options) {
207
212
  clearTimeout(timer);
208
213
  if (retries > 0 && isRetryable(error)) {
209
214
  await sleep(getBackoffDelay(maxRetries - retries));
210
- return request(method, path, body, retries - 1, internalKind);
215
+ return request(method, path, body, retries - 1, internalKind, extraHeaders);
211
216
  }
212
217
  cbRecordUnreachable();
213
218
  return {
@@ -497,6 +502,16 @@ function sleep(ms) {
497
502
  function getBackoffDelay(attempt) {
498
503
  return Math.min(1e3 * 2 ** attempt + Math.random() * 500, 1e4);
499
504
  }
505
+ function parseRetryAfter(value, now2 = Date.now()) {
506
+ if (!value) return null;
507
+ const raw = value.trim();
508
+ if (!raw) return null;
509
+ const seconds = Number(raw);
510
+ if (Number.isFinite(seconds)) return seconds < 0 ? null : seconds * 1e3;
511
+ const at = Date.parse(raw);
512
+ if (Number.isNaN(at)) return null;
513
+ return Math.max(at - now2, 0);
514
+ }
500
515
  function isRetryable(error) {
501
516
  if (error instanceof DOMException && error.name === "AbortError") return true;
502
517
  if (error instanceof TypeError) return true;
@@ -1166,6 +1181,8 @@ function createOfflineQueue(config = {}) {
1166
1181
  let syncCleanup = null;
1167
1182
  let flushInterval = null;
1168
1183
  let backendType = null;
1184
+ let inFlight = null;
1185
+ let followUp = null;
1169
1186
  async function wrapForStorage(report) {
1170
1187
  const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
1171
1188
  if (!encryptAtRest) {
@@ -1318,7 +1335,10 @@ function createOfflineQueue(config = {}) {
1318
1335
  }
1319
1336
  async function lsEnqueue(report) {
1320
1337
  const rows = lsRead();
1321
- rows.push(await wrapForStorage(report));
1338
+ const row = await wrapForStorage(report);
1339
+ const idx = rows.findIndex((r) => r.id === report.id);
1340
+ if (idx >= 0) rows[idx] = row;
1341
+ else rows.push(row);
1322
1342
  lsWrite(rows);
1323
1343
  }
1324
1344
  function lsDelete(id) {
@@ -1357,26 +1377,43 @@ function createOfflineQueue(config = {}) {
1357
1377
  return false;
1358
1378
  }
1359
1379
  }
1360
- async function enqueue(report) {
1361
- if (!enabled) return;
1362
- const currentSize = await size();
1363
- if (currentSize >= maxQueueSize) {
1364
- queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
1365
- return;
1380
+ async function readRows() {
1381
+ const backend = detectBackend();
1382
+ if (backend === "none") return { backend, rows: [] };
1383
+ if (backend === "indexeddb") {
1384
+ try {
1385
+ return { backend, rows: await idbGetAll() };
1386
+ } catch {
1387
+ backendType = "localstorage";
1388
+ }
1366
1389
  }
1390
+ return { backend: "localstorage", rows: lsRead() };
1391
+ }
1392
+ async function writeRow(report) {
1367
1393
  const backend = detectBackend();
1394
+ if (backend === "none") {
1395
+ queueLog.debug("Offline queue: no storage backend, dropping report", { id: report.id });
1396
+ return backend;
1397
+ }
1368
1398
  if (backend === "indexeddb") {
1369
1399
  try {
1370
1400
  await idbEnqueue(report);
1371
- return;
1401
+ return backend;
1372
1402
  } catch {
1373
1403
  backendType = "localstorage";
1374
1404
  }
1375
1405
  }
1376
- if (backend === "localstorage" || backendType === "localstorage") {
1377
- await lsEnqueue(report);
1406
+ await lsEnqueue(report);
1407
+ return "localstorage";
1408
+ }
1409
+ async function enqueue(report) {
1410
+ if (!enabled) return;
1411
+ const currentSize = await size();
1412
+ if (currentSize >= maxQueueSize) {
1413
+ queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
1378
1414
  return;
1379
1415
  }
1416
+ await writeRow(report);
1380
1417
  }
1381
1418
  function getBackoffDelay2(attempt) {
1382
1419
  return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
@@ -1384,19 +1421,9 @@ function createOfflineQueue(config = {}) {
1384
1421
  function sleep2(ms) {
1385
1422
  return new Promise((resolve) => setTimeout(resolve, ms));
1386
1423
  }
1387
- async function flush(client) {
1388
- if (!enabled) return { sent: 0, failed: 0 };
1389
- let rows;
1390
- const backend = detectBackend();
1391
- if (backend === "indexeddb") {
1392
- try {
1393
- rows = await idbGetAll();
1394
- } catch {
1395
- rows = lsRead();
1396
- }
1397
- } else {
1398
- rows = lsRead();
1399
- }
1424
+ async function runFlush(client) {
1425
+ const { backend, rows: storedRows } = await readRows();
1426
+ let rows = storedRows;
1400
1427
  const now2 = Date.now();
1401
1428
  const fresh = [];
1402
1429
  for (const row of rows) {
@@ -1425,59 +1452,84 @@ function createOfflineQueue(config = {}) {
1425
1452
  if (result.ok) {
1426
1453
  await removeRow(backend, rowId);
1427
1454
  sent++;
1455
+ continue;
1456
+ }
1457
+ failed++;
1458
+ if (result.error?.code === "CIRCUIT_OPEN") {
1459
+ queueLog.debug("Offline queue: circuit open, deferring the rest of the batch", {
1460
+ id: rowId,
1461
+ remaining: batch.length - i
1462
+ });
1463
+ break;
1464
+ }
1465
+ const permanent = result.error?.code === "HTTP_400" || result.error?.code === "HTTP_413" || result.error?.code === "HTTP_422" || result.error?.code === "INGEST_ERROR" || result.error?.code === "VALIDATION_ERROR" || // A payload that exceeds the size guard will never shrink on its own;
1466
+ // retrying re-serialises the multi-MB body every sync tick and wedges
1467
+ // the queue (it matches neither permanent nor transient otherwise).
1468
+ // SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
1469
+ result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
1470
+ result.error.message
1471
+ );
1472
+ const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
1473
+ if (permanent) {
1474
+ await removeRow(backend, rowId);
1428
1475
  } else {
1429
- const permanent = result.error?.code === "HTTP_400" || result.error?.code === "HTTP_413" || result.error?.code === "HTTP_422" || result.error?.code === "INGEST_ERROR" || result.error?.code === "VALIDATION_ERROR" || // A payload that exceeds the size guard will never shrink on its own;
1430
- // retrying re-serialises the multi-MB body every sync tick and wedges
1431
- // the queue (it matches neither permanent nor transient otherwise).
1432
- // SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
1433
- result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
1434
- result.error.message
1435
- );
1436
- const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
1437
- if (permanent) {
1476
+ const nextAttempts = rowAttempts(row) + 1;
1477
+ if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1438
1478
  await removeRow(backend, rowId);
1439
- } else if (transient) {
1440
- const nextAttempts = rowAttempts(row) + 1;
1441
- if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1442
- await removeRow(backend, rowId);
1443
- queueLog.warn("Offline queue: giving up on report after repeated failures", {
1479
+ queueLog.warn("Offline queue: giving up on report after repeated failures", {
1480
+ id: rowId,
1481
+ attempts: nextAttempts,
1482
+ code: result.error?.code
1483
+ });
1484
+ } else {
1485
+ row.attempts = nextAttempts;
1486
+ const persisted = await persistRow(backend, row);
1487
+ if (persisted) {
1488
+ queueLog.debug("Offline queue: delivery failed, will retry", {
1444
1489
  id: rowId,
1445
1490
  attempts: nextAttempts,
1446
- code: result.error?.code
1491
+ code: result.error?.code,
1492
+ classified: transient ? "transient" : "unclassified"
1447
1493
  });
1448
1494
  } else {
1449
- row.attempts = nextAttempts;
1450
- const persisted = await persistRow(backend, row);
1451
- if (persisted) {
1452
- queueLog.debug("Offline queue: transient failure, will retry", {
1453
- id: rowId,
1454
- attempts: nextAttempts,
1455
- code: result.error?.code
1456
- });
1457
- } else {
1458
- await removeRow(backend, rowId);
1459
- queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1460
- id: rowId,
1461
- code: result.error?.code
1462
- });
1463
- }
1495
+ await removeRow(backend, rowId);
1496
+ queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1497
+ id: rowId,
1498
+ code: result.error?.code
1499
+ });
1464
1500
  }
1465
1501
  }
1466
- failed++;
1467
- if (i < batch.length - 1) {
1468
- await sleep2(getBackoffDelay2(i));
1469
- }
1502
+ }
1503
+ if (i < batch.length - 1) {
1504
+ await sleep2(getBackoffDelay2(i));
1470
1505
  }
1471
1506
  }
1472
1507
  return { sent, failed };
1473
1508
  }
1509
+ function flush(client) {
1510
+ if (!enabled) return Promise.resolve({ sent: 0, failed: 0 });
1511
+ if (inFlight) {
1512
+ if (!followUp) {
1513
+ followUp = inFlight.catch(() => void 0).then(() => {
1514
+ followUp = null;
1515
+ return flush(client);
1516
+ });
1517
+ }
1518
+ return followUp;
1519
+ }
1520
+ const pass = runFlush(client).finally(() => {
1521
+ if (inFlight === pass) inFlight = null;
1522
+ });
1523
+ inFlight = pass;
1524
+ return pass;
1525
+ }
1474
1526
  async function size() {
1475
1527
  const backend = detectBackend();
1476
1528
  if (backend === "indexeddb") {
1477
1529
  try {
1478
1530
  return await idbSize();
1479
1531
  } catch {
1480
- return lsRead().length;
1532
+ backendType = "localstorage";
1481
1533
  }
1482
1534
  }
1483
1535
  return lsRead().length;