@mushi-mushi/core 1.27.0 → 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 · 328 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;
@@ -1056,12 +1071,27 @@ function openKeyDb() {
1056
1071
  return new Promise((resolve, reject) => {
1057
1072
  const req = indexedDB.open(KEY_DB, 1);
1058
1073
  req.onupgradeneeded = () => {
1074
+ try {
1075
+ const db = req.result;
1076
+ if (!db) return;
1077
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
1078
+ db.createObjectStore(KEY_STORE);
1079
+ }
1080
+ } catch {
1081
+ }
1082
+ };
1083
+ req.onsuccess = () => {
1059
1084
  const db = req.result;
1060
- if (!db.objectStoreNames.contains(KEY_STORE)) {
1061
- db.createObjectStore(KEY_STORE);
1085
+ if (!db || !db.objectStoreNames.contains(KEY_STORE)) {
1086
+ try {
1087
+ db?.close();
1088
+ } catch {
1089
+ }
1090
+ reject(new Error("key object store unavailable"));
1091
+ return;
1062
1092
  }
1093
+ resolve(db);
1063
1094
  };
1064
- req.onsuccess = () => resolve(req.result);
1065
1095
  req.onerror = () => reject(req.error);
1066
1096
  });
1067
1097
  }
@@ -1151,6 +1181,8 @@ function createOfflineQueue(config = {}) {
1151
1181
  let syncCleanup = null;
1152
1182
  let flushInterval = null;
1153
1183
  let backendType = null;
1184
+ let inFlight = null;
1185
+ let followUp = null;
1154
1186
  async function wrapForStorage(report) {
1155
1187
  const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
1156
1188
  if (!encryptAtRest) {
@@ -1204,12 +1236,28 @@ function createOfflineQueue(config = {}) {
1204
1236
  return new Promise((resolve, reject) => {
1205
1237
  const request = indexedDB.open(DB_NAME, DB_VERSION);
1206
1238
  request.onupgradeneeded = () => {
1239
+ try {
1240
+ const db = request.result;
1241
+ if (!db) return;
1242
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
1243
+ db.createObjectStore(STORE_NAME, { keyPath: "id" });
1244
+ }
1245
+ } catch {
1246
+ }
1247
+ };
1248
+ request.onsuccess = () => {
1207
1249
  const db = request.result;
1208
- if (!db.objectStoreNames.contains(STORE_NAME)) {
1209
- db.createObjectStore(STORE_NAME, { keyPath: "id" });
1250
+ if (!db || !db.objectStoreNames.contains(STORE_NAME)) {
1251
+ backendType = "localstorage";
1252
+ try {
1253
+ db?.close();
1254
+ } catch {
1255
+ }
1256
+ reject(new Error("queue object store unavailable"));
1257
+ return;
1210
1258
  }
1259
+ resolve(db);
1211
1260
  };
1212
- request.onsuccess = () => resolve(request.result);
1213
1261
  request.onerror = () => {
1214
1262
  backendType = "localstorage";
1215
1263
  reject(request.error);
@@ -1287,7 +1335,10 @@ function createOfflineQueue(config = {}) {
1287
1335
  }
1288
1336
  async function lsEnqueue(report) {
1289
1337
  const rows = lsRead();
1290
- 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);
1291
1342
  lsWrite(rows);
1292
1343
  }
1293
1344
  function lsDelete(id) {
@@ -1326,26 +1377,43 @@ function createOfflineQueue(config = {}) {
1326
1377
  return false;
1327
1378
  }
1328
1379
  }
1329
- async function enqueue(report) {
1330
- if (!enabled) return;
1331
- const currentSize = await size();
1332
- if (currentSize >= maxQueueSize) {
1333
- queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
1334
- 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
+ }
1335
1389
  }
1390
+ return { backend: "localstorage", rows: lsRead() };
1391
+ }
1392
+ async function writeRow(report) {
1336
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
+ }
1337
1398
  if (backend === "indexeddb") {
1338
1399
  try {
1339
1400
  await idbEnqueue(report);
1340
- return;
1401
+ return backend;
1341
1402
  } catch {
1342
1403
  backendType = "localstorage";
1343
1404
  }
1344
1405
  }
1345
- if (backend === "localstorage" || backendType === "localstorage") {
1346
- 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 });
1347
1414
  return;
1348
1415
  }
1416
+ await writeRow(report);
1349
1417
  }
1350
1418
  function getBackoffDelay2(attempt) {
1351
1419
  return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
@@ -1353,19 +1421,9 @@ function createOfflineQueue(config = {}) {
1353
1421
  function sleep2(ms) {
1354
1422
  return new Promise((resolve) => setTimeout(resolve, ms));
1355
1423
  }
1356
- async function flush(client) {
1357
- if (!enabled) return { sent: 0, failed: 0 };
1358
- let rows;
1359
- const backend = detectBackend();
1360
- if (backend === "indexeddb") {
1361
- try {
1362
- rows = await idbGetAll();
1363
- } catch {
1364
- rows = lsRead();
1365
- }
1366
- } else {
1367
- rows = lsRead();
1368
- }
1424
+ async function runFlush(client) {
1425
+ const { backend, rows: storedRows } = await readRows();
1426
+ let rows = storedRows;
1369
1427
  const now2 = Date.now();
1370
1428
  const fresh = [];
1371
1429
  for (const row of rows) {
@@ -1394,59 +1452,84 @@ function createOfflineQueue(config = {}) {
1394
1452
  if (result.ok) {
1395
1453
  await removeRow(backend, rowId);
1396
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);
1397
1475
  } else {
1398
- 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;
1399
- // retrying re-serialises the multi-MB body every sync tick and wedges
1400
- // the queue (it matches neither permanent nor transient otherwise).
1401
- // SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
1402
- 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(
1403
- result.error.message
1404
- );
1405
- 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"));
1406
- if (permanent) {
1476
+ const nextAttempts = rowAttempts(row) + 1;
1477
+ if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1407
1478
  await removeRow(backend, rowId);
1408
- } else if (transient) {
1409
- const nextAttempts = rowAttempts(row) + 1;
1410
- if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
1411
- await removeRow(backend, rowId);
1412
- 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", {
1413
1489
  id: rowId,
1414
1490
  attempts: nextAttempts,
1415
- code: result.error?.code
1491
+ code: result.error?.code,
1492
+ classified: transient ? "transient" : "unclassified"
1416
1493
  });
1417
1494
  } else {
1418
- row.attempts = nextAttempts;
1419
- const persisted = await persistRow(backend, row);
1420
- if (persisted) {
1421
- queueLog.debug("Offline queue: transient failure, will retry", {
1422
- id: rowId,
1423
- attempts: nextAttempts,
1424
- code: result.error?.code
1425
- });
1426
- } else {
1427
- await removeRow(backend, rowId);
1428
- queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1429
- id: rowId,
1430
- code: result.error?.code
1431
- });
1432
- }
1495
+ await removeRow(backend, rowId);
1496
+ queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
1497
+ id: rowId,
1498
+ code: result.error?.code
1499
+ });
1433
1500
  }
1434
1501
  }
1435
- failed++;
1436
- if (i < batch.length - 1) {
1437
- await sleep2(getBackoffDelay2(i));
1438
- }
1502
+ }
1503
+ if (i < batch.length - 1) {
1504
+ await sleep2(getBackoffDelay2(i));
1439
1505
  }
1440
1506
  }
1441
1507
  return { sent, failed };
1442
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
+ }
1443
1526
  async function size() {
1444
1527
  const backend = detectBackend();
1445
1528
  if (backend === "indexeddb") {
1446
1529
  try {
1447
1530
  return await idbSize();
1448
1531
  } catch {
1449
- return lsRead().length;
1532
+ backendType = "localstorage";
1450
1533
  }
1451
1534
  }
1452
1535
  return lsRead().length;