stitchkit 0.108.4 → 0.109.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/browser/cancellation.d.ts +4 -5
  3. package/dist/browser/cancellation.d.ts.map +1 -1
  4. package/dist/browser/client-executors.d.ts.map +1 -1
  5. package/dist/browser/http.d.ts.map +1 -1
  6. package/dist/entrypoints/agent-runtime/harness.js +4 -3
  7. package/dist/entrypoints/agent-runtime.js +7 -6
  8. package/dist/entrypoints/cli.js +6 -5
  9. package/dist/entrypoints/index.js +2 -2
  10. package/dist/entrypoints/react.js +1 -1
  11. package/dist/entrypoints/remote.js +2 -2
  12. package/dist/entrypoints/telegram.d.ts +2 -2
  13. package/dist/entrypoints/telegram.d.ts.map +1 -1
  14. package/dist/entrypoints/telegram.js +868 -137
  15. package/dist/entrypoints/testing.js +2 -2
  16. package/dist/entrypoints/tools/connections.js +6 -1
  17. package/dist/entrypoints/tools/contract.js +2 -1
  18. package/dist/entrypoints/tools/invoker.js +5 -4
  19. package/dist/entrypoints/tools/mcp.js +6 -5
  20. package/dist/entrypoints/tools.js +9 -8
  21. package/dist/{index-7jvwrjqa.js → index-00v1qz76.js} +20 -17
  22. package/dist/{index-cx2nh595.js → index-3wm2xp2a.js} +1 -1
  23. package/dist/{index-fgq4w9bf.js → index-5cw390s6.js} +100 -20
  24. package/dist/{index-h7qms8f9.js → index-68y3n9j3.js} +3 -3
  25. package/dist/{index-6yxhe58g.js → index-cykxpxz5.js} +7 -0
  26. package/dist/{index-p5kx5vqd.js → index-e32vy8jw.js} +22 -11
  27. package/dist/{index-dm2fv45z.js → index-k4v2kq4q.js} +1 -1
  28. package/dist/{index-6y9t2fwz.js → index-kvvpw1kq.js} +2 -2
  29. package/dist/{index-2s6fpehp.js → index-ryv32myz.js} +1 -1
  30. package/dist/index-t5sjh517.js +100 -0
  31. package/dist/{index-yz8x0hbx.js → index-tpk71wqn.js} +1 -1
  32. package/dist/{index-mep4sb48.js → index-x5xs71dw.js} +3 -3
  33. package/dist/telegram/update-intake-work.d.ts +39 -0
  34. package/dist/telegram/update-intake-work.d.ts.map +1 -0
  35. package/dist/telegram/update-intake.d.ts +47 -9
  36. package/dist/telegram/update-intake.d.ts.map +1 -1
  37. package/dist/telegram/update-store-check.d.ts.map +1 -1
  38. package/dist/telegram/update-store-postgres.d.ts +2 -2
  39. package/dist/telegram/update-store-postgres.d.ts.map +1 -1
  40. package/dist/telegram/update-store-sqlite.d.ts +2 -2
  41. package/dist/telegram/update-store-sqlite.d.ts.map +1 -1
  42. package/dist/telegram/update-store.d.ts +52 -3
  43. package/dist/telegram/update-store.d.ts.map +1 -1
  44. package/dist/tools/cli/args-fields.d.ts +7 -1
  45. package/dist/tools/cli/args-fields.d.ts.map +1 -1
  46. package/dist/tools/cli/args.d.ts.map +1 -1
  47. package/dist/tools/connections/runtime.d.ts.map +1 -1
  48. package/dist/tools/schema/coerce.d.ts.map +1 -1
  49. package/dist/tools/schema/json-schema-origin.d.ts +14 -0
  50. package/dist/tools/schema/json-schema-origin.d.ts.map +1 -0
  51. package/llms/cli.2.txt +1 -1
  52. package/llms/cli.txt +12 -0
  53. package/llms/stitchkit.txt +10 -3
  54. package/llms/telegram.txt +59 -8
  55. package/llms/tools.3.txt +1 -1
  56. package/llms/{upgrading-0.100.0-0.108.0.txt → upgrading-0.101.0-0.109.0.txt} +70 -72
  57. package/llms/upgrading-0.37.0.txt +176 -0
  58. package/llms/{upgrading-0.37.0-0.58.0.txt → upgrading-0.44.0-0.60.0.txt} +229 -169
  59. package/llms/{upgrading-0.59.0-0.71.0.txt → upgrading-0.61.0-0.73.0.txt} +122 -229
  60. package/llms/{upgrading-0.72.0-0.99.0.txt → upgrading-0.74.0-0.100.0.txt} +72 -122
  61. package/llms/upgrading.txt +5 -4
  62. package/llms-full.txt +150 -13
  63. package/llms.txt +9 -8
  64. package/package.json +1 -1
@@ -1220,7 +1220,226 @@ function createTelegramBotTransport(options = {}) {
1220
1220
  return exchange(socket, Buffer.concat([Buffer.from(head, "latin1"), body]), { maxResponseBytes, responseTimeoutMs }, signal);
1221
1221
  };
1222
1222
  }
1223
+ // src/telegram/update-intake-work.ts
1224
+ function createTelegramUpdateWorkSlots(maxConcurrent) {
1225
+ const waiting = [];
1226
+ let running = 0;
1227
+ return {
1228
+ async take() {
1229
+ if (running < maxConcurrent) {
1230
+ running += 1;
1231
+ return;
1232
+ }
1233
+ await new Promise((resolve) => waiting.push(resolve));
1234
+ },
1235
+ release() {
1236
+ const next = waiting.shift();
1237
+ if (next)
1238
+ next();
1239
+ else
1240
+ running -= 1;
1241
+ }
1242
+ };
1243
+ }
1244
+ function createTelegramUpdateExhaustionScheduler(options) {
1245
+ const { config, store, now, slots, parse, reportStoreError } = options;
1246
+ const handler = config.handleExhaustion;
1247
+ const jobs = new Map;
1248
+ return {
1249
+ schedule(row, active) {
1250
+ if (!active || !store || !handler || jobs.has(row.updateId))
1251
+ return false;
1252
+ const update = parse(row.body);
1253
+ if (!update)
1254
+ return false;
1255
+ const exhaustion = Object.freeze({
1256
+ update: transportResult(update),
1257
+ updateId: row.updateId,
1258
+ attempt: row.attempt,
1259
+ claimId: row.claimId,
1260
+ exhaustedAt: row.exhaustedAt,
1261
+ error: row.error
1262
+ });
1263
+ const job = (async () => {
1264
+ await slots.take();
1265
+ try {
1266
+ try {
1267
+ await handler(exhaustion);
1268
+ } catch (error) {
1269
+ try {
1270
+ config.onExhaustionFailure?.({ exhaustion, error });
1271
+ } catch {}
1272
+ return;
1273
+ }
1274
+ await store.acknowledgeExhaustion(exhaustion, now()).catch((error) => reportStoreError(error, "settle"));
1275
+ } finally {
1276
+ slots.release();
1277
+ }
1278
+ })().finally(() => {
1279
+ if (jobs.get(row.updateId) === job)
1280
+ jobs.delete(row.updateId);
1281
+ });
1282
+ jobs.set(row.updateId, job);
1283
+ return true;
1284
+ },
1285
+ pending: () => [...jobs.values()]
1286
+ };
1287
+ }
1288
+ function handlerError(error) {
1289
+ return error instanceof Error && error.name === "BotError" && "ctx" in error && "error" in error ? error.error : error;
1290
+ }
1291
+ function createTelegramUpdateAttemptRunner(options) {
1292
+ const {
1293
+ config,
1294
+ store,
1295
+ fenced,
1296
+ durable,
1297
+ now,
1298
+ retry,
1299
+ maxAttempts,
1300
+ leaseMs,
1301
+ reportStoreError,
1302
+ scheduleExhaustion,
1303
+ ownershipLostReason
1304
+ } = options;
1305
+ return async (update, updateId) => {
1306
+ const taken = now();
1307
+ const claimOptions = {
1308
+ now: taken,
1309
+ leaseUntil: taken + leaseMs,
1310
+ maxAttempts,
1311
+ ...durable && { durableExhaustion: true }
1312
+ };
1313
+ const ownedIdentity = fenced ? await fenced.claimOwned(updateId, claimOptions).catch((error) => reportStoreError(error, "claim")) : undefined;
1314
+ const legacyAttempt = fenced ? undefined : await store.claim(updateId, claimOptions).catch((error) => reportStoreError(error, "claim"));
1315
+ const attempt = ownedIdentity?.attempt ?? legacyAttempt;
1316
+ if (attempt === undefined)
1317
+ return;
1318
+ const identity = Object.freeze(ownedIdentity ?? { updateId, attempt, claimId: "" });
1319
+ const ownership = new AbortController;
1320
+ let active = true;
1321
+ const loseOwnership = () => {
1322
+ if (!ownership.signal.aborted)
1323
+ ownership.abort(ownershipLostReason(identity));
1324
+ };
1325
+ let ownershipDeadline;
1326
+ const armOwnershipDeadline = (leaseUntil) => {
1327
+ if (!active)
1328
+ return;
1329
+ if (ownershipDeadline)
1330
+ clearTimeout(ownershipDeadline);
1331
+ ownershipDeadline = setTimeout(loseOwnership, Math.max(1, leaseUntil - now()));
1332
+ ownershipDeadline.unref?.();
1333
+ };
1334
+ armOwnershipDeadline(taken + leaseMs);
1335
+ let renewalInFlight = false;
1336
+ const renewal = setInterval(() => {
1337
+ if (!active || renewalInFlight)
1338
+ return;
1339
+ renewalInFlight = true;
1340
+ const at = now();
1341
+ const leaseUntil = at + leaseMs;
1342
+ let request;
1343
+ try {
1344
+ request = fenced ? fenced.renewOwned(identity, leaseUntil, at) : store.renew(updateId, attempt, leaseUntil, at);
1345
+ } catch (error) {
1346
+ renewalInFlight = false;
1347
+ if (active)
1348
+ reportStoreError(error, "renew");
1349
+ return;
1350
+ }
1351
+ request.then((held) => {
1352
+ renewalInFlight = false;
1353
+ if (!active)
1354
+ return;
1355
+ if (held)
1356
+ armOwnershipDeadline(leaseUntil);
1357
+ else
1358
+ loseOwnership();
1359
+ }, (error) => {
1360
+ renewalInFlight = false;
1361
+ if (active)
1362
+ reportStoreError(error, "renew");
1363
+ });
1364
+ }, Math.max(1, Math.floor(leaseMs / 4)));
1365
+ renewal.unref?.();
1366
+ const context = Object.freeze({
1367
+ ...identity,
1368
+ fence: identity,
1369
+ ownerLost: ownership.signal
1370
+ });
1371
+ const settleAttempt = (settlement) => fenced ? fenced.settleOwned(identity, settlement) : store.settle(updateId, attempt, settlement).then(() => true);
1372
+ try {
1373
+ try {
1374
+ const typedUpdate = transportResult(update);
1375
+ if ("handleAttempt" in config)
1376
+ await config.handleAttempt(typedUpdate, context);
1377
+ else
1378
+ await config.handle(typedUpdate);
1379
+ const settled = await settleAttempt({ state: "completed", at: now() }).catch((error) => {
1380
+ reportStoreError(error, "settle");
1381
+ return false;
1382
+ });
1383
+ if (settled === false)
1384
+ loseOwnership();
1385
+ } catch (thrown) {
1386
+ const error = handlerError(thrown);
1387
+ const delay = attempt >= maxAttempts ? false : retry(error, attempt);
1388
+ const message = error instanceof Error ? error.message : String(error);
1389
+ let exhaustion;
1390
+ let retrying = delay !== false;
1391
+ if (delay === false && durable) {
1392
+ const at = now();
1393
+ const persisted = await durable.exhaust(identity, { at, error: message }).catch((cause) => {
1394
+ reportStoreError(cause, "settle");
1395
+ return false;
1396
+ });
1397
+ if (persisted) {
1398
+ exhaustion = {
1399
+ ...identity,
1400
+ body: JSON.stringify(update),
1401
+ exhaustedAt: at,
1402
+ error: message
1403
+ };
1404
+ } else {
1405
+ loseOwnership();
1406
+ retrying = true;
1407
+ }
1408
+ } else {
1409
+ const at = now();
1410
+ const settled = await settleAttempt(delay === false ? { state: "abandoned", at, error: message } : { state: "failed", at, retryAt: at + delay, error: message }).catch((cause) => {
1411
+ reportStoreError(cause, "settle");
1412
+ return false;
1413
+ });
1414
+ if (settled === false) {
1415
+ loseOwnership();
1416
+ retrying = true;
1417
+ }
1418
+ }
1419
+ try {
1420
+ config.onFailure?.({ updateId, attempt, error, retrying });
1421
+ } catch {}
1422
+ if (exhaustion)
1423
+ scheduleExhaustion(exhaustion);
1424
+ }
1425
+ } finally {
1426
+ active = false;
1427
+ clearInterval(renewal);
1428
+ if (ownershipDeadline)
1429
+ clearTimeout(ownershipDeadline);
1430
+ }
1431
+ };
1432
+ }
1433
+
1223
1434
  // src/telegram/update-intake.ts
1435
+ class TelegramUpdateOwnershipLostError extends Error {
1436
+ identity;
1437
+ constructor(identity) {
1438
+ super(`[stitchkit] telegram update ${identity.updateId} attempt ${identity.attempt} lost ownership`);
1439
+ this.identity = identity;
1440
+ this.name = "TelegramUpdateOwnershipLostError";
1441
+ }
1442
+ }
1224
1443
  var SWEEP_BATCH = 100;
1225
1444
  function defaultRetry(error, attempt) {
1226
1445
  const failure = classifyTelegramSendFailure2(error);
@@ -1256,17 +1475,30 @@ function parsed(body) {
1256
1475
  return;
1257
1476
  }
1258
1477
  }
1259
- function handlerError(error) {
1260
- return error instanceof Error && error.name === "BotError" && "ctx" in error && "error" in error ? error.error : error;
1261
- }
1262
1478
  function positive(name, value) {
1263
1479
  if (!Number.isSafeInteger(value) || value < 1) {
1264
1480
  throw new RangeError(`[stitchkit] telegram update intake: ${name} is a positive integer`);
1265
1481
  }
1266
1482
  return value;
1267
1483
  }
1484
+ function isTelegramUpdateFencedStore(store) {
1485
+ return "claimOwned" in store && typeof store.claimOwned === "function" && "renewOwned" in store && typeof store.renewOwned === "function" && "owns" in store && typeof store.owns === "function" && "settleOwned" in store && typeof store.settleOwned === "function";
1486
+ }
1487
+ function isTelegramUpdateDurableStore(store) {
1488
+ return isTelegramUpdateFencedStore(store) && "exhaust" in store && typeof store.exhaust === "function" && "dueExhaustions" in store && typeof store.dueExhaustions === "function" && "acknowledgeExhaustion" in store && typeof store.acknowledgeExhaustion === "function";
1489
+ }
1268
1490
  function createTelegramUpdateIntake(config) {
1269
1491
  const { store } = config;
1492
+ const exhaustionHandler = config.handleExhaustion;
1493
+ const attemptAware = "handleAttempt" in config;
1494
+ const fenced = attemptAware || exhaustionHandler !== undefined ? isTelegramUpdateFencedStore(store) ? store : undefined : undefined;
1495
+ const durable = exhaustionHandler ? isTelegramUpdateDurableStore(store) ? store : undefined : undefined;
1496
+ if (attemptAware && !fenced) {
1497
+ throw new TypeError("[stitchkit] telegram update intake: handleAttempt requires a TelegramUpdateFencedStore");
1498
+ }
1499
+ if (exhaustionHandler && !durable) {
1500
+ throw new TypeError("[stitchkit] telegram update intake: handleExhaustion requires a TelegramUpdateDurableStore");
1501
+ }
1270
1502
  const now = config.now ?? Date.now;
1271
1503
  const retry = config.retry ?? defaultRetry;
1272
1504
  const maxAttempts = positive("maxAttempts", config.maxAttempts ?? 5);
@@ -1277,66 +1509,52 @@ function createTelegramUpdateIntake(config) {
1277
1509
  const maxConcurrent = positive("maxConcurrent", config.maxConcurrent ?? 32);
1278
1510
  const lines = new Map;
1279
1511
  const scheduled = new Set;
1280
- const waiting = [];
1281
- let running = 0;
1282
1512
  let started = false;
1513
+ let starting;
1514
+ let lifecycleGeneration = 0;
1283
1515
  let timer;
1516
+ let exhaustionCursor;
1284
1517
  const storeError = (error, step) => {
1285
1518
  try {
1286
1519
  config.onStoreError?.(error, step);
1287
1520
  } catch {}
1288
1521
  };
1289
- const slot = async () => {
1290
- if (running < maxConcurrent) {
1291
- running += 1;
1292
- return;
1293
- }
1294
- await new Promise((resolve) => waiting.push(resolve));
1295
- };
1296
- const release = () => {
1297
- const next = waiting.shift();
1298
- if (next)
1299
- next();
1300
- else
1301
- running -= 1;
1302
- };
1303
- const attempt = async (update, updateId) => {
1304
- const taken = now();
1305
- const number = await store.claim(updateId, { now: taken, leaseUntil: taken + leaseMs, maxAttempts }).catch((error) => storeError(error, "claim"));
1306
- if (number === undefined)
1307
- return;
1308
- const renewal = setInterval(() => {
1309
- store.renew(updateId, number, now() + leaseMs).catch((error) => storeError(error, "renew"));
1310
- }, Math.max(1, Math.floor(leaseMs / 4)));
1311
- let settlement;
1312
- try {
1313
- await config.handle(transportResult(update));
1314
- settlement = { state: "completed", at: now() };
1315
- } catch (thrown) {
1316
- const error = handlerError(thrown);
1317
- const delay = number >= maxAttempts ? false : retry(error, number);
1318
- const message = error instanceof Error ? error.message : String(error);
1319
- settlement = delay === false ? { state: "abandoned", at: now(), error: message } : { state: "failed", at: now(), retryAt: now() + delay, error: message };
1320
- try {
1321
- config.onFailure?.({ updateId, attempt: number, error, retrying: delay !== false });
1322
- } catch {}
1323
- } finally {
1324
- clearInterval(renewal);
1325
- }
1326
- await store.settle(updateId, number, settlement).catch((error) => storeError(error, "settle"));
1327
- };
1522
+ const slots = createTelegramUpdateWorkSlots(maxConcurrent);
1523
+ const exhaustionScheduler = createTelegramUpdateExhaustionScheduler({
1524
+ config,
1525
+ store: durable,
1526
+ now,
1527
+ slots,
1528
+ parse: parsed,
1529
+ reportStoreError: storeError
1530
+ });
1531
+ const attempt = createTelegramUpdateAttemptRunner({
1532
+ config,
1533
+ store,
1534
+ fenced,
1535
+ durable,
1536
+ now,
1537
+ retry,
1538
+ maxAttempts,
1539
+ leaseMs,
1540
+ reportStoreError: storeError,
1541
+ scheduleExhaustion: (row) => {
1542
+ exhaustionScheduler.schedule(row, started);
1543
+ },
1544
+ ownershipLostReason: (identity) => new TelegramUpdateOwnershipLostError(identity)
1545
+ });
1328
1546
  const schedule = (update) => {
1329
1547
  const updateId = Number(update.update_id);
1330
1548
  if (!started || scheduled.has(updateId))
1331
- return;
1549
+ return false;
1332
1550
  scheduled.add(updateId);
1333
1551
  const line = lineOf(update);
1334
1552
  const next = (lines.get(line) ?? Promise.resolve()).then(async () => {
1335
- await slot();
1553
+ await slots.take();
1336
1554
  try {
1337
1555
  await attempt(update, updateId);
1338
1556
  } finally {
1339
- release();
1557
+ slots.release();
1340
1558
  }
1341
1559
  }).finally(() => {
1342
1560
  scheduled.delete(updateId);
@@ -1344,6 +1562,7 @@ function createTelegramUpdateIntake(config) {
1344
1562
  lines.delete(line);
1345
1563
  });
1346
1564
  lines.set(line, next);
1565
+ return true;
1347
1566
  };
1348
1567
  const sweep = async (pendingBefore) => {
1349
1568
  const at = now();
@@ -1351,9 +1570,22 @@ function createTelegramUpdateIntake(config) {
1351
1570
  let count = 0;
1352
1571
  for (const row of due) {
1353
1572
  const update = parsed(row.body);
1354
- if (update && !scheduled.has(row.updateId)) {
1355
- schedule(update);
1573
+ if (update && schedule(update))
1356
1574
  count += 1;
1575
+ }
1576
+ if (durable) {
1577
+ let exhaustions = await durable.dueExhaustions({
1578
+ limit: SWEEP_BATCH,
1579
+ ...exhaustionCursor !== undefined && { afterUpdateId: exhaustionCursor }
1580
+ });
1581
+ if (exhaustions.length === 0 && exhaustionCursor !== undefined) {
1582
+ exhaustionCursor = undefined;
1583
+ exhaustions = await durable.dueExhaustions({ limit: SWEEP_BATCH });
1584
+ }
1585
+ exhaustionCursor = exhaustions.at(-1)?.updateId;
1586
+ for (const exhaustion of exhaustions) {
1587
+ if (exhaustionScheduler.schedule(exhaustion, started))
1588
+ count += 1;
1357
1589
  }
1358
1590
  }
1359
1591
  await store.prune(at - retainMs);
@@ -1371,32 +1603,66 @@ function createTelegramUpdateIntake(config) {
1371
1603
  schedule(update);
1372
1604
  return "accepted";
1373
1605
  },
1374
- async start() {
1606
+ start() {
1607
+ if (starting)
1608
+ return starting;
1375
1609
  if (started)
1376
- return;
1610
+ return Promise.resolve();
1377
1611
  started = true;
1378
- await sweep(now());
1379
- timer = setInterval(() => {
1380
- sweep(now() - pendingGraceMs).catch((error) => storeError(error, "sweep"));
1381
- }, sweepEveryMs);
1382
- timer.unref?.();
1612
+ const generation = ++lifecycleGeneration;
1613
+ const operation = (async () => {
1614
+ try {
1615
+ await sweep(now());
1616
+ } catch (error) {
1617
+ if (started && lifecycleGeneration === generation)
1618
+ started = false;
1619
+ throw error;
1620
+ }
1621
+ if (!started || lifecycleGeneration !== generation)
1622
+ return;
1623
+ timer = setInterval(() => {
1624
+ if (!started || lifecycleGeneration !== generation)
1625
+ return;
1626
+ sweep(now() - pendingGraceMs).catch((error) => storeError(error, "sweep"));
1627
+ }, sweepEveryMs);
1628
+ timer.unref?.();
1629
+ })();
1630
+ starting = operation;
1631
+ const clearStarting = () => {
1632
+ if (starting === operation)
1633
+ starting = undefined;
1634
+ };
1635
+ operation.then(clearStarting, clearStarting);
1636
+ return operation;
1383
1637
  },
1384
1638
  sweep: () => sweep(now() - pendingGraceMs),
1385
1639
  async idle() {
1386
- while (lines.size > 0)
1387
- await Promise.all(lines.values());
1640
+ while (lines.size > 0 || exhaustionScheduler.pending().length > 0) {
1641
+ await Promise.all([...lines.values(), ...exhaustionScheduler.pending()]);
1642
+ }
1388
1643
  },
1389
1644
  async close() {
1390
1645
  started = false;
1646
+ lifecycleGeneration += 1;
1391
1647
  if (timer)
1392
1648
  clearInterval(timer);
1393
1649
  timer = undefined;
1394
- while (lines.size > 0)
1395
- await Promise.all(lines.values());
1650
+ const pendingStart = starting;
1651
+ if (pendingStart) {
1652
+ await pendingStart.then(() => {
1653
+ return;
1654
+ }, () => {
1655
+ return;
1656
+ });
1657
+ }
1658
+ while (lines.size > 0 || exhaustionScheduler.pending().length > 0) {
1659
+ await Promise.all([...lines.values(), ...exhaustionScheduler.pending()]);
1660
+ }
1396
1661
  }
1397
1662
  };
1398
1663
  }
1399
1664
  // src/telegram/update-store.ts
1665
+ import { randomUUID } from "node:crypto";
1400
1666
  function isClaimable(record, now) {
1401
1667
  if (record.state === "pending")
1402
1668
  return true;
@@ -1406,9 +1672,41 @@ function isClaimable(record, now) {
1406
1672
  }
1407
1673
  function memoryTelegramUpdateStore() {
1408
1674
  const records = new Map;
1409
- const holding = (updateId, attempt) => {
1675
+ const holding = (updateId, attempt, claimId) => {
1676
+ const record = records.get(updateId);
1677
+ return record?.state === "processing" && record.attempts === attempt && (claimId === undefined || record.claimId === claimId) ? record : undefined;
1678
+ };
1679
+ const settleCurrent = (updateId, attempt, settlement, claimId) => {
1680
+ const record = holding(updateId, attempt, claimId);
1681
+ if (!record || record.dueAt < settlement.at)
1682
+ return false;
1683
+ record.state = settlement.state;
1684
+ record.error = settlement.state === "completed" ? undefined : settlement.error;
1685
+ if (settlement.state === "failed")
1686
+ record.dueAt = settlement.retryAt;
1687
+ else
1688
+ record.settledAt = settlement.at;
1689
+ return true;
1690
+ };
1691
+ const claimOwned = (updateId, options) => {
1410
1692
  const record = records.get(updateId);
1411
- return record?.state === "processing" && record.attempts === attempt ? record : undefined;
1693
+ if (!record || !isClaimable(record, options.now))
1694
+ return;
1695
+ if (record.attempts >= options.maxAttempts) {
1696
+ Object.assign(record, {
1697
+ state: "abandoned",
1698
+ dueAt: options.now,
1699
+ settledAt: options.durableExhaustion ? undefined : options.now,
1700
+ error: "attempts spent",
1701
+ claimId: record.claimId ?? randomUUID()
1702
+ });
1703
+ return;
1704
+ }
1705
+ record.state = "processing";
1706
+ record.attempts += 1;
1707
+ record.dueAt = options.leaseUntil;
1708
+ record.claimId = randomUUID();
1709
+ return { updateId, attempt: record.attempts, claimId: record.claimId };
1412
1710
  };
1413
1711
  return {
1414
1712
  async add(update) {
@@ -1420,47 +1718,76 @@ function memoryTelegramUpdateStore() {
1420
1718
  attempts: 0,
1421
1719
  dueAt: update.receivedAt,
1422
1720
  settledAt: undefined,
1423
- error: undefined
1721
+ error: undefined,
1722
+ claimId: undefined
1424
1723
  });
1425
1724
  return true;
1426
1725
  },
1427
1726
  async claim(updateId, options) {
1428
- const record = records.get(updateId);
1429
- if (!record || !isClaimable(record, options.now))
1430
- return;
1431
- if (record.attempts >= options.maxAttempts) {
1432
- Object.assign(record, {
1433
- state: "abandoned",
1434
- settledAt: options.now,
1435
- error: "attempts spent"
1436
- });
1437
- return;
1438
- }
1439
- record.state = "processing";
1440
- record.attempts += 1;
1441
- record.dueAt = options.leaseUntil;
1442
- return record.attempts;
1727
+ return claimOwned(updateId, options)?.attempt;
1728
+ },
1729
+ async claimOwned(updateId, options) {
1730
+ return claimOwned(updateId, options);
1443
1731
  },
1444
- async renew(updateId, attempt, leaseUntil) {
1732
+ async renew(updateId, attempt, leaseUntil, at) {
1445
1733
  const record = holding(updateId, attempt);
1446
- if (record)
1447
- record.dueAt = leaseUntil;
1448
- return record !== undefined;
1734
+ if (!record || at !== undefined && record.dueAt < at)
1735
+ return false;
1736
+ record.dueAt = Math.max(record.dueAt, leaseUntil);
1737
+ return true;
1449
1738
  },
1450
1739
  async settle(updateId, attempt, settlement) {
1451
- const record = holding(updateId, attempt);
1452
- if (!record)
1453
- return;
1454
- record.state = settlement.state;
1455
- record.error = settlement.state === "completed" ? undefined : settlement.error;
1456
- if (settlement.state === "failed")
1457
- record.dueAt = settlement.retryAt;
1458
- else
1459
- record.settledAt = settlement.at;
1740
+ settleCurrent(updateId, attempt, settlement);
1460
1741
  },
1461
1742
  async due(query) {
1462
1743
  return [...records.values()].filter((record) => record.state === "pending" ? record.receivedAt <= query.pendingBefore : isClaimable(record, query.now)).sort((left, right) => left.updateId - right.updateId).slice(0, query.limit).map(({ updateId, body }) => ({ updateId, body }));
1463
1744
  },
1745
+ async owns(identity, at) {
1746
+ const record = holding(identity.updateId, identity.attempt, identity.claimId);
1747
+ return record !== undefined && record.dueAt >= at;
1748
+ },
1749
+ async renewOwned(identity, leaseUntil, at) {
1750
+ const record = holding(identity.updateId, identity.attempt, identity.claimId);
1751
+ if (!record || at !== undefined && record.dueAt < at)
1752
+ return false;
1753
+ record.dueAt = Math.max(record.dueAt, leaseUntil);
1754
+ return true;
1755
+ },
1756
+ async settleOwned(identity, settlement) {
1757
+ return settleCurrent(identity.updateId, identity.attempt, settlement, identity.claimId);
1758
+ },
1759
+ async exhaust(identity, exhaustion) {
1760
+ const record = holding(identity.updateId, identity.attempt, identity.claimId);
1761
+ if (!record || record.dueAt < exhaustion.at)
1762
+ return false;
1763
+ Object.assign(record, {
1764
+ state: "abandoned",
1765
+ dueAt: exhaustion.at,
1766
+ settledAt: undefined,
1767
+ error: exhaustion.error
1768
+ });
1769
+ return true;
1770
+ },
1771
+ async dueExhaustions(query) {
1772
+ return [...records.values()].filter((record) => record.state === "abandoned" && record.settledAt === undefined && (query.afterUpdateId === undefined || record.updateId > query.afterUpdateId)).sort((left, right) => left.updateId - right.updateId).slice(0, query.limit).flatMap(({ updateId, body, attempts, dueAt, error, claimId }) => claimId ? [
1773
+ {
1774
+ updateId,
1775
+ body,
1776
+ attempt: attempts,
1777
+ claimId,
1778
+ exhaustedAt: dueAt,
1779
+ error: error ?? "attempts spent"
1780
+ }
1781
+ ] : []);
1782
+ },
1783
+ async acknowledgeExhaustion(identity, at) {
1784
+ const record = records.get(identity.updateId);
1785
+ if (record?.state !== "abandoned" || record.attempts !== identity.attempt || record.claimId !== identity.claimId || record.settledAt !== undefined) {
1786
+ return false;
1787
+ }
1788
+ record.settledAt = at;
1789
+ return true;
1790
+ },
1464
1791
  async prune(before) {
1465
1792
  let pruned = 0;
1466
1793
  for (const [updateId, record] of records) {
@@ -1478,6 +1805,12 @@ function same(actual, expected) {
1478
1805
  return JSON.stringify(actual) === JSON.stringify(expected);
1479
1806
  }
1480
1807
  var claim = { maxAttempts: 2 };
1808
+ function fenced(store) {
1809
+ return "claimOwned" in store && typeof store.claimOwned === "function" && "renewOwned" in store && typeof store.renewOwned === "function" && "owns" in store && typeof store.owns === "function" && "settleOwned" in store && typeof store.settleOwned === "function";
1810
+ }
1811
+ function durable(store) {
1812
+ return fenced(store) && "exhaust" in store && typeof store.exhaust === "function" && "dueExhaustions" in store && typeof store.dueExhaustions === "function" && "acknowledgeExhaustion" in store && typeof store.acknowledgeExhaustion === "function";
1813
+ }
1481
1814
  var RULES = {
1482
1815
  "one record per update_id": async (store, violated) => {
1483
1816
  if (!await store.add({ updateId: 7, body: "b", receivedAt: 0 }))
@@ -1502,7 +1835,13 @@ var RULES = {
1502
1835
  },
1503
1836
  "only the attempt holding the update renews or settles it": async (store, violated) => {
1504
1837
  await store.add({ updateId: 1, body: "b", receivedAt: 0 });
1505
- await store.claim(1, { ...claim, now: 0, leaseUntil: 10 });
1838
+ const owned = fenced(store) ? store : undefined;
1839
+ const firstIdentity = owned ? await owned.claimOwned(1, { ...claim, now: 0, leaseUntil: 10 }) : undefined;
1840
+ if (!owned)
1841
+ await store.claim(1, { ...claim, now: 0, leaseUntil: 10 });
1842
+ if (firstIdentity && owned && await owned.settleOwned(firstIdentity, { state: "completed", at: 11 })) {
1843
+ violated("an attempt whose lease expired settled the update");
1844
+ }
1506
1845
  await store.claim(1, { ...claim, now: 11, leaseUntil: 50 });
1507
1846
  if (await store.renew(1, 1, 500))
1508
1847
  violated("an attempt that lost its lease renewed it");
@@ -1536,7 +1875,7 @@ var RULES = {
1536
1875
  },
1537
1876
  "settled updates are forgotten after they are pruned": async (store, violated) => {
1538
1877
  await store.add({ updateId: 3, body: "b", receivedAt: 0 });
1539
- await store.claim(3, { ...claim, now: 0, leaseUntil: 10 });
1878
+ await store.claim(3, { ...claim, now: 0, leaseUntil: 100 });
1540
1879
  await store.settle(3, 1, { state: "abandoned", at: 36, error: "x" });
1541
1880
  await store.add({ updateId: 4, body: "b", receivedAt: 0 });
1542
1881
  if (await store.prune(36) !== 0)
@@ -1553,6 +1892,199 @@ var RULES = {
1553
1892
  const taken = await Promise.all(Array.from({ length: 8 }, () => store.claim(9, { maxAttempts: 5, now: 1, leaseUntil: 100 })));
1554
1893
  if (taken.filter((attempt) => attempt !== undefined).length !== 1)
1555
1894
  violated(`${taken.filter((attempt) => attempt !== undefined).length} of 8 concurrent claims took one update`);
1895
+ },
1896
+ "of concurrent fenced claims exactly one takes the update": async (store, violated) => {
1897
+ if (!fenced(store))
1898
+ return;
1899
+ await store.add({ updateId: 19, body: "b", receivedAt: 0 });
1900
+ const taken = await Promise.all(Array.from({ length: 8 }, () => store.claimOwned(19, { maxAttempts: 5, now: 1, leaseUntil: 100 })));
1901
+ if (taken.filter((identity) => identity !== undefined).length !== 1) {
1902
+ violated(`${taken.filter((identity) => identity !== undefined).length} of 8 concurrent fenced claims took one update`);
1903
+ }
1904
+ },
1905
+ "renewal never shortens a live lease": async (store, violated) => {
1906
+ await store.add({ updateId: 18, body: "b", receivedAt: 0 });
1907
+ const identity = fenced(store) ? await store.claimOwned(18, { ...claim, now: 0, leaseUntil: 10 }) : undefined;
1908
+ if (!identity)
1909
+ await store.claim(18, { ...claim, now: 0, leaseUntil: 10 });
1910
+ if (!await store.renew(18, 1, 30, 1)) {
1911
+ violated("the live attempt could not extend its lease");
1912
+ return;
1913
+ }
1914
+ if (!await store.renew(18, 1, 20, 2)) {
1915
+ violated("an older live renewal was not accepted");
1916
+ return;
1917
+ }
1918
+ if ((await store.due({ now: 21, pendingBefore: 0, limit: 10 })).length !== 0) {
1919
+ violated("an older renewal shortened the current lease");
1920
+ }
1921
+ if (identity && fenced(store)) {
1922
+ if (!await store.renewOwned(identity, 40, 3)) {
1923
+ violated("the fenced attempt could not extend its lease");
1924
+ return;
1925
+ }
1926
+ if (!await store.renewOwned(identity, 35, 4)) {
1927
+ violated("an older fenced renewal was not accepted");
1928
+ return;
1929
+ }
1930
+ if (!await store.owns(identity, 39)) {
1931
+ violated("an older fenced renewal shortened the current lease");
1932
+ }
1933
+ }
1934
+ },
1935
+ "an attempt fence expires and never adopts a later claim": async (store, violated) => {
1936
+ if (!fenced(store))
1937
+ return;
1938
+ await store.add({ updateId: 10, body: "b", receivedAt: 0 });
1939
+ const first = await store.claimOwned(10, { ...claim, now: 0, leaseUntil: 10 });
1940
+ if (!first) {
1941
+ violated("the first fenced claim was not taken");
1942
+ return;
1943
+ }
1944
+ if (!await store.owns(first, 10))
1945
+ violated("the current attempt did not own its lease at the bound");
1946
+ const wrongFirst = { ...first, claimId: `${first.claimId}-wrong` };
1947
+ if (await store.owns(wrongFirst, 10))
1948
+ violated("a different claim token owned the current attempt");
1949
+ if (await store.renewOwned(wrongFirst, 10, 0))
1950
+ violated("a different claim token renewed the current attempt");
1951
+ if (await store.owns(first, 11))
1952
+ violated("an expired attempt still owned its lease");
1953
+ if (await store.renewOwned(first, 50, 11))
1954
+ violated("an expired attempt resurrected its lease");
1955
+ const second = await store.claimOwned(10, { ...claim, now: 11, leaseUntil: 30 });
1956
+ if (!second) {
1957
+ violated("the later fenced claim was not taken");
1958
+ return;
1959
+ }
1960
+ if (await store.owns(first, 12))
1961
+ violated("the old attempt adopted the later claim");
1962
+ if (!await store.owns(second, 12))
1963
+ violated("the later attempt did not own its claim");
1964
+ if (!await store.settleOwned(second, { state: "completed", at: 12 }))
1965
+ violated("the later attempt could not settle its claim");
1966
+ if (await store.prune(13) !== 1) {
1967
+ violated("the settled fenced attempt could not be pruned");
1968
+ return;
1969
+ }
1970
+ await store.add({ updateId: 10, body: "new b", receivedAt: 13 });
1971
+ const recycled = await store.claimOwned(10, { ...claim, now: 13, leaseUntil: 30 });
1972
+ if (!recycled) {
1973
+ violated("the recycled update_id could not be claimed");
1974
+ return;
1975
+ }
1976
+ if (recycled.attempt !== first.attempt)
1977
+ violated("the recycled update_id did not restart its attempt number");
1978
+ const wrongRecycled = { ...recycled, claimId: `${recycled.claimId}-wrong` };
1979
+ if (await store.renewOwned(wrongRecycled, 40, 14))
1980
+ violated("a different claim token renewed a recycled update_id");
1981
+ if (await store.settleOwned(wrongRecycled, { state: "completed", at: 14 }))
1982
+ violated("a different claim token settled a recycled update_id");
1983
+ if (await store.owns(first, 14))
1984
+ violated("a pruned claim identity adopted a recycled update_id");
1985
+ if (await store.renewOwned(first, 40, 14))
1986
+ violated("a pruned claim identity renewed a recycled update_id");
1987
+ if (await store.settleOwned(first, { state: "completed", at: 14 }))
1988
+ violated("a pruned claim identity settled a recycled update_id");
1989
+ },
1990
+ "durable exhaustion survives owner loss until its exact acknowledgement": async (store, violated) => {
1991
+ if (!durable(store))
1992
+ return;
1993
+ await store.add({ updateId: 11, body: "crash", receivedAt: 0 });
1994
+ const first = await store.claimOwned(11, {
1995
+ now: 0,
1996
+ leaseUntil: 10,
1997
+ maxAttempts: 1,
1998
+ durableExhaustion: true
1999
+ });
2000
+ if (!first) {
2001
+ violated("the durable claim was not taken");
2002
+ return;
2003
+ }
2004
+ if (await store.claimOwned(11, {
2005
+ now: 11,
2006
+ leaseUntil: 20,
2007
+ maxAttempts: 1,
2008
+ durableExhaustion: true
2009
+ }) !== undefined) {
2010
+ violated("an attempts-spent update was claimed again");
2011
+ }
2012
+ const [exhaustion] = await store.dueExhaustions({ limit: 10 });
2013
+ if (exhaustion?.updateId !== 11 || exhaustion.attempt !== 1 || exhaustion.body !== "crash") {
2014
+ violated("the attempts-spent abandonment was not recoverable with its identity and body");
2015
+ return;
2016
+ }
2017
+ if (await store.prune(1000) !== 0)
2018
+ violated("prune removed an unacknowledged exhaustion");
2019
+ if (await store.acknowledgeExhaustion({ ...exhaustion, claimId: `${exhaustion.claimId}-wrong` }, 20)) {
2020
+ violated("a different claim token acknowledged the exhaustion");
2021
+ }
2022
+ if (await store.acknowledgeExhaustion({ ...exhaustion, attempt: 2 }, 20))
2023
+ violated("a different attempt acknowledged the exhaustion");
2024
+ if (!await store.acknowledgeExhaustion(exhaustion, 20))
2025
+ violated("the exact attempt could not acknowledge the exhaustion");
2026
+ if ((await store.dueExhaustions({ limit: 10 })).length !== 0)
2027
+ violated("an acknowledged exhaustion remained due");
2028
+ if (await store.prune(21) !== 1)
2029
+ violated("an acknowledged exhaustion could not be pruned");
2030
+ await store.add({ updateId: 12, body: "terminal", receivedAt: 0 });
2031
+ const terminal = await store.claimOwned(12, { now: 0, leaseUntil: 10, maxAttempts: 2 });
2032
+ if (!terminal) {
2033
+ violated("the terminal claim was not taken");
2034
+ return;
2035
+ }
2036
+ if (await store.exhaust({ ...terminal, claimId: `${terminal.claimId}-wrong` }, { at: 1, error: "wrong terminal refusal" })) {
2037
+ violated("a different claim token persisted terminal exhaustion");
2038
+ }
2039
+ if (!await store.exhaust(terminal, { at: 1, error: "terminal refusal" })) {
2040
+ violated("the holding attempt could not persist its terminal exhaustion");
2041
+ }
2042
+ if ((await store.dueExhaustions({ limit: 10 }))[0]?.error !== "terminal refusal")
2043
+ violated("the terminal exhaustion did not preserve its reason");
2044
+ await store.add({ updateId: 13, body: "expired", receivedAt: 0 });
2045
+ const expired = await store.claimOwned(13, { now: 0, leaseUntil: 10, maxAttempts: 2 });
2046
+ if (!expired) {
2047
+ violated("the expiring claim was not taken");
2048
+ return;
2049
+ }
2050
+ if (await store.exhaust(expired, { at: 11, error: "late terminal refusal" })) {
2051
+ violated("an expired attempt persisted a terminal exhaustion");
2052
+ }
2053
+ if ((await store.claimOwned(13, { now: 11, leaseUntil: 20, maxAttempts: 2 }))?.attempt !== 2)
2054
+ violated("an expired terminal attempt prevented a later claim");
2055
+ await store.add({ updateId: 14, body: "next terminal", receivedAt: 0 });
2056
+ const next = await store.claimOwned(14, { now: 0, leaseUntil: 10, maxAttempts: 2 });
2057
+ if (!next) {
2058
+ violated("the next terminal claim was not taken");
2059
+ return;
2060
+ }
2061
+ await store.exhaust(next, { at: 1, error: "next terminal refusal" });
2062
+ if (!same((await store.dueExhaustions({ limit: 10, afterUpdateId: 12 })).map(({ updateId }) => updateId), [14])) {
2063
+ violated("exhaustion pagination did not continue after its update_id cursor");
2064
+ }
2065
+ await store.add({ updateId: 15, body: "base durable", receivedAt: 0 });
2066
+ if (await store.claim(15, {
2067
+ now: 0,
2068
+ leaseUntil: 10,
2069
+ maxAttempts: 1,
2070
+ durableExhaustion: true
2071
+ }) !== 1) {
2072
+ violated("the base durable claim was not taken");
2073
+ return;
2074
+ }
2075
+ await store.claim(15, {
2076
+ now: 11,
2077
+ leaseUntil: 20,
2078
+ maxAttempts: 1,
2079
+ durableExhaustion: true
2080
+ });
2081
+ const baseExhaustion = (await store.dueExhaustions({ limit: 20 })).find(({ updateId }) => updateId === 15);
2082
+ if (!baseExhaustion) {
2083
+ violated("a base durable claim became an invisible exhaustion");
2084
+ return;
2085
+ }
2086
+ if (!await store.acknowledgeExhaustion(baseExhaustion, 20))
2087
+ violated("a base durable exhaustion could not be acknowledged");
1556
2088
  }
1557
2089
  };
1558
2090
  async function checkTelegramUpdateStore(make) {
@@ -1567,6 +2099,7 @@ async function checkTelegramUpdateStore(make) {
1567
2099
  return violations;
1568
2100
  }
1569
2101
  // src/telegram/update-store-postgres.ts
2102
+ import { randomUUID as randomUUID2 } from "node:crypto";
1570
2103
  var TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
1571
2104
  function checkedTable(table) {
1572
2105
  if (!TABLE_NAME.test(table)) {
@@ -1584,8 +2117,10 @@ function postgresTelegramUpdateStoreSchema(table = "telegram_updates") {
1584
2117
  due_at BIGINT NOT NULL,
1585
2118
  received_at BIGINT NOT NULL,
1586
2119
  settled_at BIGINT,
1587
- error TEXT
2120
+ error TEXT,
2121
+ claim_id TEXT
1588
2122
  );
2123
+ ALTER TABLE ${name} ADD COLUMN IF NOT EXISTS claim_id TEXT;
1589
2124
  CREATE INDEX IF NOT EXISTS ${name}_due ON ${name} (state, due_at);`;
1590
2125
  }
1591
2126
  var CLAIMABLE = `(state = 'pending' OR (state = 'failed' AND due_at <= $1::bigint)
@@ -1601,7 +2136,7 @@ function integer(value) {
1601
2136
  function field(row, name) {
1602
2137
  return typeof row === "object" && row !== null && name in row ? Reflect.get(row, name) : undefined;
1603
2138
  }
1604
- function postgresTelegramUpdateStore(config) {
2139
+ function createPostgresTelegramUpdateContext(config) {
1605
2140
  const table = checkedTable(config.table ?? "telegram_updates");
1606
2141
  const { query } = config;
1607
2142
  let ready;
@@ -1617,6 +2152,54 @@ function postgresTelegramUpdateStore(config) {
1617
2152
  }
1618
2153
  return query(text, parameters);
1619
2154
  };
2155
+ const settleOwned = async (updateId, attempt, settlement, claimId) => {
2156
+ const rows = await run(`UPDATE ${table} SET state = $1::text, due_at = COALESCE($2::bigint, due_at),
2157
+ settled_at = $3::bigint, error = $4::text
2158
+ WHERE update_id = $5::bigint AND attempts = $6::integer AND state = 'processing'
2159
+ AND due_at >= $7::bigint
2160
+ AND claim_id = $8::text
2161
+ RETURNING update_id`, [
2162
+ settlement.state,
2163
+ settlement.state === "failed" ? settlement.retryAt : null,
2164
+ settlement.state === "failed" ? null : settlement.at,
2165
+ settlement.state === "completed" ? null : settlement.error,
2166
+ updateId,
2167
+ attempt,
2168
+ settlement.at,
2169
+ claimId
2170
+ ]);
2171
+ return rows.length > 0;
2172
+ };
2173
+ const takeAttempt = async (updateId, options) => {
2174
+ const claimId = randomUUID2();
2175
+ const [row] = await run(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1,
2176
+ due_at = $2::bigint, error = NULL, claim_id = $5::text
2177
+ WHERE update_id = $3::bigint AND attempts < $4::integer AND ${CLAIMABLE}
2178
+ RETURNING attempts, claim_id`, [options.now, options.leaseUntil, updateId, options.maxAttempts, claimId]);
2179
+ const attempt = integer(field(row, "attempts"));
2180
+ const persistedClaimId = field(row, "claim_id");
2181
+ if (attempt !== undefined && typeof persistedClaimId === "string") {
2182
+ return { updateId, attempt, claimId: persistedClaimId };
2183
+ }
2184
+ await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
2185
+ settled_at = CASE WHEN $4::integer = 1 THEN NULL ELSE $1::bigint END,
2186
+ error = 'attempts spent', claim_id = COALESCE(claim_id, $5::text)
2187
+ WHERE update_id = $2::bigint AND attempts >= $3::integer AND ${CLAIMABLE}
2188
+ RETURNING update_id`, [options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0, claimId]);
2189
+ return;
2190
+ };
2191
+ const renewCurrent = async (updateId, attempt, leaseUntil, at, claimId) => {
2192
+ const rows = await run(`UPDATE ${table} SET due_at = GREATEST(due_at, $1::bigint)
2193
+ WHERE update_id = $2::bigint AND attempts = $3::integer AND state = 'processing'
2194
+ AND ($4::bigint IS NULL OR due_at >= $4::bigint)
2195
+ AND claim_id = $5::text
2196
+ RETURNING update_id`, [leaseUntil, updateId, attempt, at ?? null, claimId]);
2197
+ return rows.length > 0;
2198
+ };
2199
+ return { table, run, settleOwned, takeAttempt, renewCurrent };
2200
+ }
2201
+ function postgresTelegramUpdateStore(config) {
2202
+ const { table, run, settleOwned, takeAttempt, renewCurrent } = createPostgresTelegramUpdateContext(config);
1620
2203
  return {
1621
2204
  async add(update) {
1622
2205
  const rows = await run(`INSERT INTO ${table} (update_id, body, state, attempts, due_at, received_at)
@@ -1625,35 +2208,45 @@ function postgresTelegramUpdateStore(config) {
1625
2208
  return rows.length > 0;
1626
2209
  },
1627
2210
  async claim(updateId, options) {
2211
+ if (options.durableExhaustion)
2212
+ return (await takeAttempt(updateId, options))?.attempt;
1628
2213
  const [row] = await run(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1,
1629
2214
  due_at = $2::bigint, error = NULL
1630
2215
  WHERE update_id = $3::bigint AND attempts < $4::integer AND ${CLAIMABLE}
1631
2216
  RETURNING attempts`, [options.now, options.leaseUntil, updateId, options.maxAttempts]);
1632
- const attempts = integer(field(row, "attempts"));
1633
- if (attempts !== undefined)
1634
- return attempts;
1635
- await run(`UPDATE ${table} SET state = 'abandoned', settled_at = $1::bigint, error = 'attempts spent'
2217
+ const attempt = integer(field(row, "attempts"));
2218
+ if (attempt !== undefined)
2219
+ return attempt;
2220
+ await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
2221
+ settled_at = CASE WHEN $4::integer = 1 THEN NULL ELSE $1::bigint END,
2222
+ error = 'attempts spent'
1636
2223
  WHERE update_id = $2::bigint AND attempts >= $3::integer AND ${CLAIMABLE}
1637
- RETURNING update_id`, [options.now, updateId, options.maxAttempts]);
2224
+ RETURNING update_id`, [options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0]);
1638
2225
  return;
1639
2226
  },
1640
- async renew(updateId, attempt, leaseUntil) {
1641
- const rows = await run(`UPDATE ${table} SET due_at = $1::bigint
2227
+ async claimOwned(updateId, options) {
2228
+ return takeAttempt(updateId, options);
2229
+ },
2230
+ async renew(updateId, attempt, leaseUntil, at) {
2231
+ const rows = await run(`UPDATE ${table} SET due_at = GREATEST(due_at, $1::bigint)
1642
2232
  WHERE update_id = $2::bigint AND attempts = $3::integer AND state = 'processing'
1643
- RETURNING update_id`, [leaseUntil, updateId, attempt]);
2233
+ AND ($4::bigint IS NULL OR due_at >= $4::bigint)
2234
+ RETURNING update_id`, [leaseUntil, updateId, attempt, at ?? null]);
1644
2235
  return rows.length > 0;
1645
2236
  },
1646
2237
  async settle(updateId, attempt, settlement) {
1647
2238
  await run(`UPDATE ${table} SET state = $1::text, due_at = COALESCE($2::bigint, due_at),
1648
2239
  settled_at = $3::bigint, error = $4::text
1649
2240
  WHERE update_id = $5::bigint AND attempts = $6::integer AND state = 'processing'
2241
+ AND due_at >= $7::bigint
1650
2242
  RETURNING update_id`, [
1651
2243
  settlement.state,
1652
2244
  settlement.state === "failed" ? settlement.retryAt : null,
1653
2245
  settlement.state === "failed" ? null : settlement.at,
1654
2246
  settlement.state === "completed" ? null : settlement.error,
1655
2247
  updateId,
1656
- attempt
2248
+ attempt,
2249
+ settlement.at
1657
2250
  ]);
1658
2251
  },
1659
2252
  async due(options) {
@@ -1667,6 +2260,56 @@ function postgresTelegramUpdateStore(config) {
1667
2260
  return updateId !== undefined && typeof body === "string" ? [{ updateId, body }] : [];
1668
2261
  });
1669
2262
  },
2263
+ async owns(identity, at) {
2264
+ const rows = await run(`SELECT update_id FROM ${table}
2265
+ WHERE update_id = $1::bigint AND attempts = $2::integer
2266
+ AND state = 'processing' AND due_at >= $3::bigint
2267
+ AND claim_id = $4::text
2268
+ FOR UPDATE`, [identity.updateId, identity.attempt, at, identity.claimId]);
2269
+ return rows.length > 0;
2270
+ },
2271
+ async renewOwned(identity, leaseUntil, at) {
2272
+ return renewCurrent(identity.updateId, identity.attempt, leaseUntil, at, identity.claimId);
2273
+ },
2274
+ async settleOwned(identity, settlement) {
2275
+ return settleOwned(identity.updateId, identity.attempt, settlement, identity.claimId);
2276
+ },
2277
+ async exhaust(identity, exhaustion) {
2278
+ const rows = await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
2279
+ settled_at = NULL, error = $2::text
2280
+ WHERE update_id = $3::bigint AND attempts = $4::integer AND state = 'processing'
2281
+ AND due_at >= $1::bigint AND claim_id = $5::text
2282
+ RETURNING update_id`, [
2283
+ exhaustion.at,
2284
+ exhaustion.error,
2285
+ identity.updateId,
2286
+ identity.attempt,
2287
+ identity.claimId
2288
+ ]);
2289
+ return rows.length > 0;
2290
+ },
2291
+ async dueExhaustions(options) {
2292
+ const rows = await run(`SELECT update_id, body, attempts, due_at, error, claim_id FROM ${table}
2293
+ WHERE state = 'abandoned' AND settled_at IS NULL
2294
+ AND ($2::bigint IS NULL OR update_id > $2::bigint)
2295
+ ORDER BY update_id LIMIT $1::integer`, [options.limit, options.afterUpdateId ?? null]);
2296
+ return rows.flatMap((row) => {
2297
+ const updateId = integer(field(row, "update_id"));
2298
+ const body = field(row, "body");
2299
+ const attempt = integer(field(row, "attempts"));
2300
+ const exhaustedAt = integer(field(row, "due_at"));
2301
+ const error = field(row, "error");
2302
+ const claimId = field(row, "claim_id");
2303
+ return updateId !== undefined && typeof body === "string" && attempt !== undefined && typeof claimId === "string" && exhaustedAt !== undefined && typeof error === "string" ? [{ updateId, body, attempt, claimId, exhaustedAt, error }] : [];
2304
+ });
2305
+ },
2306
+ async acknowledgeExhaustion(identity, at) {
2307
+ const rows = await run(`UPDATE ${table} SET settled_at = $1::bigint
2308
+ WHERE update_id = $2::bigint AND attempts = $3::integer
2309
+ AND state = 'abandoned' AND settled_at IS NULL AND claim_id = $4::text
2310
+ RETURNING update_id`, [at, identity.updateId, identity.attempt, identity.claimId]);
2311
+ return rows.length > 0;
2312
+ },
1670
2313
  async prune(before) {
1671
2314
  const rows = await run(`DELETE FROM ${table} WHERE state IN ('completed', 'abandoned') AND settled_at < $1::bigint
1672
2315
  RETURNING update_id`, [before]);
@@ -1675,9 +2318,68 @@ function postgresTelegramUpdateStore(config) {
1675
2318
  };
1676
2319
  }
1677
2320
  // src/telegram/update-store-sqlite.ts
2321
+ import { randomUUID as randomUUID3 } from "node:crypto";
1678
2322
  var TABLE_NAME2 = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
1679
2323
  var CLAIMABLE2 = `(state = 'pending' OR (state = 'failed' AND due_at <= ?1)
1680
2324
  OR (state = 'processing' AND due_at < ?1))`;
2325
+ function prepareTelegramSqliteUpdateStatements(database, table) {
2326
+ database.exec(`CREATE TABLE IF NOT EXISTS ${table} (
2327
+ update_id INTEGER PRIMARY KEY,
2328
+ body TEXT NOT NULL,
2329
+ state TEXT NOT NULL,
2330
+ attempts INTEGER NOT NULL,
2331
+ due_at INTEGER NOT NULL,
2332
+ received_at INTEGER NOT NULL,
2333
+ settled_at INTEGER,
2334
+ error TEXT,
2335
+ claim_id TEXT
2336
+ );
2337
+ CREATE INDEX IF NOT EXISTS ${table}_due ON ${table} (state, due_at);`);
2338
+ const hasClaimId = () => database.prepare(`PRAGMA table_info(${table})`).all().some((row) => typeof row === "object" && row !== null && ("name" in row) && row.name === "claim_id");
2339
+ if (!hasClaimId()) {
2340
+ try {
2341
+ database.exec(`ALTER TABLE ${table} ADD COLUMN claim_id TEXT`);
2342
+ } catch (error) {
2343
+ if (!hasClaimId())
2344
+ throw error;
2345
+ }
2346
+ }
2347
+ return {
2348
+ insert: database.prepare(`INSERT OR IGNORE INTO ${table} (update_id, body, state, attempts, due_at, received_at)
2349
+ VALUES (?1, ?2, 'pending', 0, ?3, ?3)`),
2350
+ take: database.prepare(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1, due_at = ?2,
2351
+ error = NULL, claim_id = ?5
2352
+ WHERE update_id = ?3 AND attempts < ?4 AND ${CLAIMABLE2}
2353
+ RETURNING attempts, claim_id`),
2354
+ spend: database.prepare(`UPDATE ${table} SET state = 'abandoned', due_at = ?1,
2355
+ settled_at = CASE WHEN ?4 = 1 THEN NULL ELSE ?1 END, error = 'attempts spent',
2356
+ claim_id = COALESCE(claim_id, ?5)
2357
+ WHERE update_id = ?2 AND attempts >= ?3 AND ${CLAIMABLE2}`),
2358
+ renew: database.prepare(`UPDATE ${table} SET due_at = MAX(due_at, ?1)
2359
+ WHERE update_id = ?2 AND attempts = ?3 AND state = 'processing'
2360
+ AND (?4 IS NULL OR due_at >= ?4) AND (?5 IS NULL OR claim_id = ?5)`),
2361
+ settle: database.prepare(`UPDATE ${table} SET state = ?1, due_at = COALESCE(?2, due_at), settled_at = ?3, error = ?4
2362
+ WHERE update_id = ?5 AND attempts = ?6 AND state = 'processing' AND due_at >= ?7
2363
+ AND (?8 IS NULL OR claim_id = ?8)`),
2364
+ due: database.prepare(`SELECT update_id, body FROM ${table}
2365
+ WHERE (state = 'pending' AND received_at <= ?2) OR (state <> 'pending' AND ${CLAIMABLE2})
2366
+ ORDER BY update_id LIMIT ?3`),
2367
+ prune: database.prepare(`DELETE FROM ${table} WHERE state IN ('completed', 'abandoned') AND settled_at < ?1`),
2368
+ owns: database.prepare(`SELECT update_id FROM ${table}
2369
+ WHERE update_id = ?1 AND attempts = ?2 AND state = 'processing' AND due_at >= ?3
2370
+ AND claim_id = ?4`),
2371
+ exhaust: database.prepare(`UPDATE ${table} SET state = 'abandoned', due_at = ?1, settled_at = NULL, error = ?2
2372
+ WHERE update_id = ?3 AND attempts = ?4 AND state = 'processing' AND due_at >= ?1
2373
+ AND claim_id = ?5`),
2374
+ dueExhaustions: database.prepare(`SELECT update_id, body, attempts, due_at, error, claim_id FROM ${table}
2375
+ WHERE state = 'abandoned' AND settled_at IS NULL
2376
+ AND (?2 IS NULL OR update_id > ?2)
2377
+ ORDER BY update_id LIMIT ?1`),
2378
+ acknowledgeExhaustion: database.prepare(`UPDATE ${table} SET settled_at = ?1
2379
+ WHERE update_id = ?2 AND attempts = ?3 AND state = 'abandoned' AND settled_at IS NULL
2380
+ AND claim_id = ?4`)
2381
+ };
2382
+ }
1681
2383
  function rowOf(row) {
1682
2384
  if (typeof row !== "object" || row === null)
1683
2385
  return;
@@ -1685,57 +2387,85 @@ function rowOf(row) {
1685
2387
  const body = "body" in row ? row.body : undefined;
1686
2388
  return typeof updateId === "number" && typeof body === "string" ? { updateId, body } : undefined;
1687
2389
  }
2390
+ function exhaustionOf(row) {
2391
+ if (typeof row !== "object" || row === null)
2392
+ return;
2393
+ const updateId = "update_id" in row ? row.update_id : undefined;
2394
+ const body = "body" in row ? row.body : undefined;
2395
+ const attempt = "attempts" in row ? row.attempts : undefined;
2396
+ const exhaustedAt = "due_at" in row ? row.due_at : undefined;
2397
+ const error = "error" in row ? row.error : undefined;
2398
+ const claimId = "claim_id" in row ? row.claim_id : undefined;
2399
+ return typeof updateId === "number" && typeof body === "string" && typeof attempt === "number" && typeof claimId === "string" && typeof exhaustedAt === "number" && typeof error === "string" ? { updateId, body, attempt, claimId, exhaustedAt, error } : undefined;
2400
+ }
1688
2401
  function sqliteTelegramUpdateStore(config) {
1689
2402
  const table = config.table ?? "telegram_updates";
1690
2403
  if (!TABLE_NAME2.test(table)) {
1691
2404
  throw new TypeError("[stitchkit] telegram update store: a table name is [A-Za-z_][A-Za-z0-9_]*");
1692
2405
  }
1693
2406
  const database = config.database;
1694
- database.exec(`CREATE TABLE IF NOT EXISTS ${table} (
1695
- update_id INTEGER PRIMARY KEY,
1696
- body TEXT NOT NULL,
1697
- state TEXT NOT NULL,
1698
- attempts INTEGER NOT NULL,
1699
- due_at INTEGER NOT NULL,
1700
- received_at INTEGER NOT NULL,
1701
- settled_at INTEGER,
1702
- error TEXT
1703
- );
1704
- CREATE INDEX IF NOT EXISTS ${table}_due ON ${table} (state, due_at);`);
1705
- const insert = database.prepare(`INSERT OR IGNORE INTO ${table} (update_id, body, state, attempts, due_at, received_at)
1706
- VALUES (?1, ?2, 'pending', 0, ?3, ?3)`);
1707
- const take = database.prepare(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1, due_at = ?2, error = NULL
1708
- WHERE update_id = ?3 AND attempts < ?4 AND ${CLAIMABLE2} RETURNING attempts`);
1709
- const spend = database.prepare(`UPDATE ${table} SET state = 'abandoned', settled_at = ?1, error = 'attempts spent'
1710
- WHERE update_id = ?2 AND attempts >= ?3 AND ${CLAIMABLE2}`);
1711
- const renew = database.prepare(`UPDATE ${table} SET due_at = ?1 WHERE update_id = ?2 AND attempts = ?3 AND state = 'processing'`);
1712
- const settle = database.prepare(`UPDATE ${table} SET state = ?1, due_at = COALESCE(?2, due_at), settled_at = ?3, error = ?4
1713
- WHERE update_id = ?5 AND attempts = ?6 AND state = 'processing'`);
1714
- const due = database.prepare(`SELECT update_id, body FROM ${table}
1715
- WHERE (state = 'pending' AND received_at <= ?2) OR (state <> 'pending' AND ${CLAIMABLE2})
1716
- ORDER BY update_id LIMIT ?3`);
1717
- const prune = database.prepare(`DELETE FROM ${table} WHERE state IN ('completed', 'abandoned') AND settled_at < ?1`);
2407
+ const {
2408
+ insert,
2409
+ take,
2410
+ spend,
2411
+ renew,
2412
+ settle,
2413
+ due,
2414
+ prune,
2415
+ owns,
2416
+ exhaust,
2417
+ dueExhaustions,
2418
+ acknowledgeExhaustion
2419
+ } = prepareTelegramSqliteUpdateStatements(database, table);
2420
+ const settleCurrent = (updateId, attempt, settlement, claimId) => Number(settle.run(settlement.state, settlement.state === "failed" ? settlement.retryAt : null, settlement.state === "failed" ? null : settlement.at, settlement.state === "completed" ? null : settlement.error, updateId, attempt, settlement.at, claimId ?? null).changes) > 0;
2421
+ const takeAttempt = (updateId, options) => {
2422
+ const claimId = randomUUID3();
2423
+ const [row] = take.all(options.now, options.leaseUntil, updateId, options.maxAttempts, claimId);
2424
+ const attempt = typeof row === "object" && row !== null && "attempts" in row ? row.attempts : undefined;
2425
+ const persistedClaimId = typeof row === "object" && row !== null && "claim_id" in row ? row.claim_id : undefined;
2426
+ if (typeof attempt === "number" && typeof persistedClaimId === "string") {
2427
+ return { updateId, attempt, claimId: persistedClaimId };
2428
+ }
2429
+ spend.run(options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0, claimId);
2430
+ return;
2431
+ };
1718
2432
  return {
1719
2433
  async add(update) {
1720
2434
  return Number(insert.run(update.updateId, update.body, update.receivedAt).changes) > 0;
1721
2435
  },
1722
2436
  async claim(updateId, options) {
1723
- const [row] = take.all(options.now, options.leaseUntil, updateId, options.maxAttempts);
1724
- const attempts = typeof row === "object" && row !== null && "attempts" in row ? row.attempts : undefined;
1725
- if (typeof attempts === "number")
1726
- return attempts;
1727
- spend.run(options.now, updateId, options.maxAttempts);
1728
- return;
2437
+ return takeAttempt(updateId, options)?.attempt;
2438
+ },
2439
+ async claimOwned(updateId, options) {
2440
+ return takeAttempt(updateId, options);
1729
2441
  },
1730
- async renew(updateId, attempt, leaseUntil) {
1731
- return Number(renew.run(leaseUntil, updateId, attempt).changes) > 0;
2442
+ async renew(updateId, attempt, leaseUntil, at) {
2443
+ return Number(renew.run(leaseUntil, updateId, attempt, at ?? null, null).changes) > 0;
1732
2444
  },
1733
2445
  async settle(updateId, attempt, settlement) {
1734
- settle.run(settlement.state, settlement.state === "failed" ? settlement.retryAt : null, settlement.state === "failed" ? null : settlement.at, settlement.state === "completed" ? null : settlement.error, updateId, attempt);
2446
+ settleCurrent(updateId, attempt, settlement);
1735
2447
  },
1736
2448
  async due(query) {
1737
2449
  return due.all(query.now, query.pendingBefore, query.limit).flatMap((row) => rowOf(row) ?? []);
1738
2450
  },
2451
+ async owns(identity, at) {
2452
+ return owns.all(identity.updateId, identity.attempt, at, identity.claimId).length > 0;
2453
+ },
2454
+ async renewOwned(identity, leaseUntil, at) {
2455
+ return Number(renew.run(leaseUntil, identity.updateId, identity.attempt, at ?? null, identity.claimId).changes) > 0;
2456
+ },
2457
+ async settleOwned(identity, settlement) {
2458
+ return settleCurrent(identity.updateId, identity.attempt, settlement, identity.claimId);
2459
+ },
2460
+ async exhaust(identity, exhaustion) {
2461
+ return Number(exhaust.run(exhaustion.at, exhaustion.error, identity.updateId, identity.attempt, identity.claimId).changes) > 0;
2462
+ },
2463
+ async dueExhaustions(query) {
2464
+ return dueExhaustions.all(query.limit, query.afterUpdateId ?? null).flatMap((row) => exhaustionOf(row) ?? []);
2465
+ },
2466
+ async acknowledgeExhaustion(identity, at) {
2467
+ return Number(acknowledgeExhaustion.run(at, identity.updateId, identity.attempt, identity.claimId).changes) > 0;
2468
+ },
1739
2469
  async prune(before) {
1740
2470
  return Number(prune.run(before).changes);
1741
2471
  }
@@ -1857,6 +2587,7 @@ export {
1857
2587
  TelegramBroadcastFailureSchema,
1858
2588
  TelegramLocalFileError,
1859
2589
  TelegramNotDispatchedError2 as TelegramNotDispatchedError,
2590
+ TelegramUpdateOwnershipLostError,
1860
2591
  TelegramWebhookClaimError,
1861
2592
  callTelegramBotApi,
1862
2593
  checkTelegramUpdateStore,