experimental-a2 0.17.0 → 0.18.1

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 (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -259,16 +259,18 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
259
259
  return row
260
260
  }
261
261
 
262
- const appendResult = (sessionId: string, events: StoredEvent[]) => {
262
+ const hasPending = (sessionId: string): boolean => {
263
263
  const log = sessions.get(sessionId)
264
- return {
265
- events,
266
- hasPending: (log?.rows ?? []).some(
267
- (row) => row.index > log!.clearIndex && row.processedAt === null,
268
- ),
269
- }
264
+ return (log?.rows ?? []).some(
265
+ (row) => row.index > log!.clearIndex && row.processedAt === null,
266
+ )
270
267
  }
271
268
 
269
+ const appendResult = (sessionId: string, events: StoredEvent[]) => ({
270
+ events,
271
+ hasPending: hasPending(sessionId),
272
+ })
273
+
272
274
  /** The attempt-currency fence shared by handler appends and clears. */
273
275
  const assertCurrentCause = (sessionId: string, cause: EventCause): void => {
274
276
  if (cause.index <= clearIndexOf(sessionId)) {
@@ -280,12 +282,35 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
280
282
  }
281
283
  }
282
284
 
283
- const claim = (
284
- row: Row,
285
- holder: string,
286
- now: Date,
287
- expiresAt: Date,
288
- ): StoredEvent => {
285
+ const claim = ({
286
+ sessionId,
287
+ row,
288
+ holder,
289
+ now,
290
+ expiresAt,
291
+ maxFailures,
292
+ }: {
293
+ sessionId: string
294
+ row: Row
295
+ holder: string
296
+ now: Date
297
+ expiresAt: Date
298
+ maxFailures: number | undefined
299
+ }): StoredEvent => {
300
+ // A claim still held here lapsed: its attempt never finished.
301
+ if (row.claimHolder !== null) {
302
+ row.failureCount += 1
303
+ if (maxFailures !== undefined && row.failureCount >= maxFailures) {
304
+ // Advancing the attempt fences the lapsed one; nothing runs.
305
+ row.attemptCount += 1
306
+ row.failedAt = new Date(now)
307
+ row.claimHolder = null
308
+ row.claimExpiresAt = null
309
+ dispatchOf(sessionId).ready.delete(row.index)
310
+ releaseSnapshotPins(sessionId, row.index)
311
+ return toStored(row)
312
+ }
313
+ }
289
314
  row.attemptCount += 1
290
315
  row.firstClaimedAt ??= new Date(now)
291
316
  row.lastClaimedAt = new Date(now)
@@ -545,6 +570,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
545
570
  ttlMs,
546
571
  expiresAtMs,
547
572
  excludeIndexes,
573
+ maxFailures,
548
574
  }) {
549
575
  const claimedAt = clock.now()
550
576
  const now = claimedAt.getTime()
@@ -572,7 +598,14 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
572
598
  return {
573
599
  outcome: 'claimed',
574
600
  events: eligible.map((row) => {
575
- const claimed = claim(row, holder, claimedAt, expiresAt)
601
+ const claimed = claim({
602
+ sessionId,
603
+ row,
604
+ holder,
605
+ now: claimedAt,
606
+ expiresAt,
607
+ maxFailures,
608
+ })
576
609
  if (clearIndex > 0) claimed.clearIndex = clearIndex
577
610
  return claimed
578
611
  }),
@@ -679,7 +712,14 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
679
712
  return { outcome: 'completed', events: inserted.map(toStored) }
680
713
  },
681
714
 
682
- async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
715
+ async failAttempt({
716
+ sessionId,
717
+ index,
718
+ attempt,
719
+ error,
720
+ maxFailures,
721
+ retryAfterMs,
722
+ }) {
683
723
  if (index <= clearIndexOf(sessionId)) {
684
724
  return { outcome: 'superseded', failureCount: 0 }
685
725
  }
@@ -706,7 +746,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
706
746
  row.lastFailedAt = new Date(failedAt)
707
747
  row.lastFailedAttempt = attempt
708
748
  row.claimHolder = null
709
- row.claimExpiresAt = null
749
+ row.claimExpiresAt =
750
+ retryAfterMs === undefined || row.failureCount >= maxFailures
751
+ ? null
752
+ : new Date(failedAt.getTime() + retryAfterMs)
710
753
  if (row.failureCount >= maxFailures) {
711
754
  row.failedAt = new Date(failedAt)
712
755
  dispatchOf(sessionId).ready.delete(row.index)
@@ -719,6 +762,39 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
719
762
  return { outcome: 'failed', failureCount: row.failureCount }
720
763
  },
721
764
 
765
+ async deadLetters(sessionId) {
766
+ const log = sessions.get(sessionId)
767
+ return (log?.rows ?? [])
768
+ .filter(
769
+ (row) =>
770
+ row.index > log!.clearIndex &&
771
+ row.processedAt === null &&
772
+ row.failedAt !== null,
773
+ )
774
+ .map(toStored)
775
+ },
776
+
777
+ async redrive({ sessionId, index, action }) {
778
+ const row =
779
+ index > clearIndexOf(sessionId) ? rowAt(sessionId, index) : undefined
780
+ if (!row || row.processedAt !== null || row.failedAt === null) {
781
+ return { redriven: false, hasPending: hasPending(sessionId) }
782
+ }
783
+ if (action === 'skip') {
784
+ row.processedAt = clock.now()
785
+ settle(sessionId, row)
786
+ } else {
787
+ row.failedAt = null
788
+ row.failureCount = 0
789
+ const state = dispatchOf(sessionId)
790
+ const queue = row.lane === null ? undefined : state.lanes.get(row.lane)
791
+ if (row.lane === null || queue?.rows[queue.head] === row) {
792
+ state.ready.set(row.index, row)
793
+ }
794
+ }
795
+ return { redriven: true, hasPending: hasPending(sessionId) }
796
+ },
797
+
722
798
  async readState(sessionId, reducerName, stateOptions) {
723
799
  return readStateOf(
724
800
  sessionId,
@@ -350,6 +350,34 @@ const createListener = ({
350
350
  return listenerConnection
351
351
  })
352
352
 
353
+ /** A dead-lettered event's snapshot pins, and history no pin keeps. */
354
+ const releasePins = async ({
355
+ query,
356
+ sessionId,
357
+ index,
358
+ }: {
359
+ query: PostgresConnection['query']
360
+ sessionId: string
361
+ index: number
362
+ }): Promise<void> => {
363
+ await query(
364
+ `delete from a2_snapshot_pins
365
+ where session_id = $1 and event_index = $2`,
366
+ [sessionId, index],
367
+ )
368
+ await query(
369
+ `delete from a2_snapshot_history history
370
+ where history.session_id = $1
371
+ and not exists (
372
+ select 1 from a2_snapshot_pins pin
373
+ where pin.session_id = history.session_id
374
+ and pin.reducer_name = history.reducer_name
375
+ and pin.up_to_index = history.up_to_index
376
+ )`,
377
+ [sessionId],
378
+ )
379
+ }
380
+
353
381
  export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
354
382
  const clock = options.clock ?? SYSTEM_CLOCK
355
383
  const generateId = options.ids ?? RANDOM_IDS
@@ -942,11 +970,16 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
942
970
  ttlMs,
943
971
  expiresAtMs,
944
972
  excludeIndexes = [],
973
+ maxFailures,
945
974
  }) {
946
975
  return wrap(() =>
947
976
  withTx(async (query) => {
948
977
  const now = clock.now().getTime()
949
978
  const expiresAt = expiresAtMs ?? now + ttlMs
979
+ // A lapsed claim's takeover that spends the last failed attempt
980
+ // dead-letters the event instead of claiming it.
981
+ const exhausted = `(claimed.claim_holder is not null
982
+ and claimed.failure_count + 1 >= $6::int)`
950
983
  const { rows } = await query(
951
984
  `with eligible as materialized (
952
985
  select event.idx
@@ -962,15 +995,36 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
962
995
  )
963
996
  update a2_events claimed set
964
997
  attempt_count = claimed.attempt_count + 1,
965
- first_claimed_at = coalesce(claimed.first_claimed_at, $2),
966
- last_claimed_at = $2,
967
- claim_holder = $3,
968
- claim_expires_at = $4
998
+ failure_count = claimed.failure_count
999
+ + case when claimed.claim_holder is null then 0 else 1 end,
1000
+ failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
1001
+ first_claimed_at = case when ${exhausted}
1002
+ then claimed.first_claimed_at
1003
+ else coalesce(claimed.first_claimed_at, $2::bigint) end,
1004
+ last_claimed_at = case when ${exhausted}
1005
+ then claimed.last_claimed_at else $2::bigint end,
1006
+ claim_holder = case when ${exhausted} then null else $3::text end,
1007
+ claim_expires_at = case when ${exhausted} then null else $4::bigint end
969
1008
  from eligible
970
1009
  where claimed.session_id = $1 and claimed.idx = eligible.idx
971
1010
  returning claimed.*, ${clearIndexOf('$1')} as clear_index`,
972
- [sessionId, now, holder, expiresAt, [...excludeIndexes]],
1011
+ [
1012
+ sessionId,
1013
+ now,
1014
+ holder,
1015
+ expiresAt,
1016
+ [...excludeIndexes],
1017
+ maxFailures ?? null,
1018
+ ],
973
1019
  )
1020
+ for (const row of rows) {
1021
+ if (row['failed_at'] !== null)
1022
+ await releasePins({
1023
+ query,
1024
+ sessionId,
1025
+ index: asNumber(row['idx']),
1026
+ })
1027
+ }
974
1028
  if (rows.length > 0) {
975
1029
  const events = rows
976
1030
  .map((row) => {
@@ -990,8 +1044,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
990
1044
  and failed_at is null
991
1045
  and lane_ready
992
1046
  and idx > ${clearIndexOf('$1')}
993
- and claim_expires_at > $2`,
994
- [sessionId, now],
1047
+ and claim_expires_at > $2
1048
+ and not (idx = any($3::bigint[]))`,
1049
+ [sessionId, now, [...excludeIndexes]],
995
1050
  )
996
1051
  const retryAt = activeRows[0]?.['retry_at']
997
1052
  return retryAt === null || retryAt === undefined
@@ -1262,7 +1317,15 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1262
1317
  )
1263
1318
  },
1264
1319
 
1265
- async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
1320
+ async failAttempt({
1321
+ sessionId,
1322
+ index,
1323
+ attempt,
1324
+ error,
1325
+ maxFailures,
1326
+ retryAfterMs,
1327
+ }) {
1328
+ const now = clock.now().getTime()
1266
1329
  return wrap(() =>
1267
1330
  withTx(async (query) => {
1268
1331
  const { rows } = await query(
@@ -1282,7 +1345,10 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1282
1345
  else e.failed_at
1283
1346
  end,
1284
1347
  claim_holder = null,
1285
- claim_expires_at = null
1348
+ claim_expires_at = case
1349
+ when e.failure_count + 1 >= $5 then null
1350
+ else $7::bigint
1351
+ end
1286
1352
  from current
1287
1353
  where e.session_id = $1
1288
1354
  and e.idx = $2
@@ -1322,7 +1388,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1322
1388
  attempt,
1323
1389
  error,
1324
1390
  maxFailures,
1325
- clock.now().getTime(),
1391
+ now,
1392
+ retryAfterMs === undefined ? null : now + retryAfterMs,
1326
1393
  ],
1327
1394
  )
1328
1395
  const row = rows[0]
@@ -1337,28 +1404,92 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1337
1404
  `no event at index ${index} in session '${sessionId}'`,
1338
1405
  )
1339
1406
  }
1340
- if (row['outcome'] === 'dead_lettered') {
1407
+ if (row['outcome'] === 'dead_lettered')
1408
+ await releasePins({ query, sessionId, index })
1409
+ return {
1410
+ outcome: row['outcome'] as
1411
+ 'failed' | 'dead_lettered' | 'superseded',
1412
+ failureCount: asNumber(row['failure_count']),
1413
+ }
1414
+ }),
1415
+ )
1416
+ },
1417
+
1418
+ async deadLetters(sessionId) {
1419
+ return wrap(async () => {
1420
+ const c = await client()
1421
+ const { rows } = await c.query(
1422
+ `select * from a2_events
1423
+ where session_id = $1
1424
+ and processed_at is null
1425
+ and failed_at is not null
1426
+ and idx > ${clearIndexOf('$1')}
1427
+ order by idx`,
1428
+ [sessionId],
1429
+ )
1430
+ return rows.map(toStored)
1431
+ })
1432
+ },
1433
+
1434
+ async redrive({ sessionId, index, action }) {
1435
+ return wrap(() =>
1436
+ withTx(async (query) => {
1437
+ // Lane promotion must see every append that committed before it,
1438
+ // so a skip takes the session lock first, in append's order. The
1439
+ // row lock serializes both actions with claims and handler appends.
1440
+ if (action === 'skip') {
1441
+ await query('select pg_advisory_xact_lock(hashtext($1))', [
1442
+ sessionId,
1443
+ ])
1444
+ }
1445
+ const { rows } = await query(
1446
+ `select lane from a2_events
1447
+ where session_id = $1 and idx = $2
1448
+ and idx > ${clearIndexOf('$1')}
1449
+ and processed_at is null
1450
+ and failed_at is not null
1451
+ for update`,
1452
+ [sessionId, index],
1453
+ )
1454
+ const row = rows[0]
1455
+ if (row && action === 'retry') {
1341
1456
  await query(
1342
- `delete from a2_snapshot_pins
1343
- where session_id = $1 and event_index = $2`,
1457
+ `update a2_events set failed_at = null, failure_count = 0
1458
+ where session_id = $1 and idx = $2`,
1344
1459
  [sessionId, index],
1345
1460
  )
1461
+ } else if (row) {
1346
1462
  await query(
1347
- `delete from a2_snapshot_history history
1348
- where history.session_id = $1
1349
- and not exists (
1350
- select 1 from a2_snapshot_pins pin
1351
- where pin.session_id = history.session_id
1352
- and pin.reducer_name = history.reducer_name
1353
- and pin.up_to_index = history.up_to_index
1354
- )`,
1355
- [sessionId],
1463
+ `update a2_events set processed_at = $3, lane_ready = false
1464
+ where session_id = $1 and idx = $2`,
1465
+ [sessionId, index, clock.now().getTime()],
1356
1466
  )
1467
+ const lane = (row['lane'] as string | null) ?? null
1468
+ if (lane !== null) {
1469
+ await query(
1470
+ `update a2_events set lane_ready = true
1471
+ where session_id = $1
1472
+ and idx = (
1473
+ select idx from a2_events
1474
+ where session_id = $1 and lane = $2 and processed_at is null
1475
+ and idx > ${clearIndexOf('$1')}
1476
+ order by idx limit 1
1477
+ )`,
1478
+ [sessionId, lane],
1479
+ )
1480
+ }
1357
1481
  }
1482
+ const { rows: status } = await query(
1483
+ `select exists(
1484
+ select 1 from a2_events
1485
+ where session_id = $1 and processed_at is null
1486
+ and idx > ${clearIndexOf('$1')}
1487
+ ) as has_pending`,
1488
+ [sessionId],
1489
+ )
1358
1490
  return {
1359
- outcome: row['outcome'] as
1360
- 'failed' | 'dead_lettered' | 'superseded',
1361
- failureCount: asNumber(row['failure_count']),
1491
+ redriven: row !== undefined,
1492
+ hasPending: status[0]?.['has_pending'] === true,
1362
1493
  }
1363
1494
  }),
1364
1495
  )