turbopuffer 2.1.0.pre.alpha.1 → 2.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a814ab0c5687f91477030e939f26d9ee96154dc1d0236377f406cd788d60cce7
4
- data.tar.gz: 10e4bbefbaf97630ac5312f87482bc015766b94097f8ede4dbde7cf88cae4baa
3
+ metadata.gz: f5782a65079f1aa648db3dde706b11543e0e35a2c93eb14f32d64c42bdc260e5
4
+ data.tar.gz: 5878b8bf8b11267e60cf1c6edd2f045e6ae427c0da3c12adec20b239179a693f
5
5
  SHA512:
6
- metadata.gz: a9004f09f663e6fb6149488e5d29826bfafb37fb1f49b910f718c52d5ae7f1894913a045b4b949fd7379a4e4aff0a5c27b5f98e63a0b0af17048c5b9c22ec430
7
- data.tar.gz: c1faf2b9e10cbd8e5654f2441df0226bf4c89ea15c8085a8e3e7a884a02a226cecb79a82078fbc493053314ed5a6795ea2b5bd3fd5c0d8b0e9d4e2c53cec5064
6
+ metadata.gz: 4716b1484413ac6c3b33583cccb0138c3068b41d7711e5062d81265dcff09dec7ad1508ca6468e08bbaebb1d0390794aaa71b1e026156841e75d6b228cdd71ba
7
+ data.tar.gz: a8cdfc20d9779db1e32922c6b23053163da52bf1dbe71f4d1555f95ea64ede5ea95c479d62f2975e70213d21ed7e4d754ab77e6f7266587f2029d2ab64e14542
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 (2026-06-08)
4
+
5
+ Full Changelog: [v2.1.0...v2.2.0](https://github.com/turbopuffer/turbopuffer-ruby/compare/v2.1.0...v2.2.0)
6
+
7
+ ### Features
8
+
9
+ * stainless: update sdks to support case-insensitive fuzzy filter ([d02bb79](https://github.com/turbopuffer/turbopuffer-ruby/commit/d02bb79d809f3b2285020f3aac4a3441ac1e38b2))
10
+ * transparent async polling ([#61](https://github.com/turbopuffer/turbopuffer-ruby/issues/61)) ([d93c499](https://github.com/turbopuffer/turbopuffer-ruby/commit/d93c49924d0df4bb4be9935f2090ce6fa26da18e))
11
+
12
+ ## 2.1.0 (2026-06-03)
13
+
14
+ Full Changelog: [v2.1.0-alpha.1...v2.1.0](https://github.com/turbopuffer/turbopuffer-ruby/compare/v2.1.0-alpha.1...v2.1.0)
15
+
16
+ ### Features
17
+
18
+ * spec: add support for word_v4 tokenizer ([26742f8](https://github.com/turbopuffer/turbopuffer-ruby/commit/26742f83e6e29055f521320f8f6c111fe040ac38))
19
+
3
20
  ## 2.1.0-alpha.1 (2026-06-02)
4
21
 
5
22
  Full Changelog: [v2.0.1...v2.1.0-alpha.1](https://github.com/turbopuffer/turbopuffer-ruby/compare/v2.0.1...v2.1.0-alpha.1)
data/README.md CHANGED
@@ -26,7 +26,7 @@ To use this gem, install via Bundler by adding the following to your application
26
26
  <!-- x-release-please-start-version -->
27
27
 
28
28
  ```ruby
29
- gem "turbopuffer", "~> 2.1.0.pre.alpha.1"
29
+ gem "turbopuffer", "~> 2.2.0"
30
30
  ```
31
31
 
32
32
  <!-- x-release-please-end -->
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbopuffer
4
+ module Internal
5
+ # Support for transparent polling of async tpuf APIs.
6
+ #
7
+ # Every API request is stamped with `Prefer: respond-async`. If the server
8
+ # applies the preference (i.e. responds with `202 Accepted` +
9
+ # `preference-applied: respond-async`) the SDK polls that URL until the
10
+ # operation finishes and returns the final result as if the API call had
11
+ # been synchronous.
12
+ #
13
+ # @api private
14
+ module RespondAsync
15
+ HEADER_PREFER = "prefer"
16
+ HEADER_PREFERENCE_APPLIED = "preference-applied"
17
+ HEADER_LOCATION = "location"
18
+ RESPOND_ASYNC = "respond-async"
19
+
20
+ POLL_INTERVAL = 1.0
21
+ POLL_REQUEST_TIMEOUT = 60.0
22
+
23
+ # Timeout tracking for async polling.
24
+ #
25
+ # @api private
26
+ class Timeout
27
+ # @param timeout [Float]
28
+ def initialize(timeout)
29
+ @deadline = Turbopuffer::Internal::Util.monotonic_secs + timeout
30
+ end
31
+
32
+ # @return [Float]
33
+ def remaining
34
+ [@deadline - Turbopuffer::Internal::Util.monotonic_secs, 0].max
35
+ end
36
+
37
+ # @return [Float]
38
+ def poll_timeout
39
+ [remaining, POLL_REQUEST_TIMEOUT].min
40
+ end
41
+
42
+ # @return [Float]
43
+ def sleep_duration
44
+ [remaining, POLL_INTERVAL].min
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # @param headers [Hash{String=>String}]
50
+ #
51
+ # @return [void]
52
+ def prepare_headers(headers)
53
+ headers[HEADER_PREFER] ||= RESPOND_ASYNC
54
+ end
55
+
56
+ # @param client [Turbopuffer::Internal::Transport::BaseClient]
57
+ # @param request [Hash{Symbol=>Object}] the original built request
58
+ # @param response [Net::HTTPResponse]
59
+ # @param stream [Enumerable<String>]
60
+ #
61
+ # @return [Array(Integer, Net::HTTPResponse, Enumerable<String>)]
62
+ def maybe_poll(client, request, response, stream)
63
+ return [Integer(response.code), response, stream] unless respond_async_applied?(response)
64
+
65
+ # Drain the original 202 body so the connection returns to the pool.
66
+ stream&.each { next }
67
+
68
+ orig_url = request.fetch(:url)
69
+ location = extract_location(orig_url, response)
70
+
71
+ timeout = Timeout.new(request.fetch(:timeout))
72
+
73
+ loop do
74
+ result = poll_once(client, orig_url, location, timeout)
75
+ return result if result
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # @param response [Net::HTTPResponse]
82
+ #
83
+ # @return [Boolean]
84
+ def respond_async_applied?(response)
85
+ return false unless response.code == "202"
86
+
87
+ applied = response[HEADER_PREFERENCE_APPLIED].to_s
88
+ applied.strip.downcase == RESPOND_ASYNC
89
+ end
90
+
91
+ # @param orig_url [URI::Generic]
92
+ # @param response [Net::HTTPResponse]
93
+ #
94
+ # @return [URI::Generic]
95
+ def extract_location(orig_url, response)
96
+ raw_location = response[HEADER_LOCATION].to_s.strip
97
+ if raw_location.empty?
98
+ raise Turbopuffer::Errors::APIError.new(
99
+ url: orig_url,
100
+ message: "Server returned async response without a 'Location' header."
101
+ )
102
+ end
103
+
104
+ # Normalize so the default port for the scheme is applied.
105
+ orig = URI(orig_url.to_s)
106
+
107
+ # Resolve the Location against the original request URL.
108
+ begin
109
+ location = URI.join(orig, raw_location)
110
+ rescue URI::InvalidURIError
111
+ raise Turbopuffer::Errors::APIError.new(
112
+ url: orig_url,
113
+ message: "malformed 'Location' header: #{raw_location.inspect}"
114
+ )
115
+ end
116
+
117
+ if [location.scheme, location.host, location.port] !=
118
+ [orig.scheme, orig.host, orig.port]
119
+ raise Turbopuffer::Errors::APIError.new(
120
+ url: orig_url,
121
+ message: "'Location' origin does not match request origin: #{raw_location.inspect}"
122
+ )
123
+ end
124
+
125
+ location
126
+ end
127
+
128
+ # @param client [Turbopuffer::Internal::Transport::BaseClient]
129
+ # @param orig_url [URI::Generic]
130
+ # @param location [URI::Generic]
131
+ # @param timeout [Timeout]
132
+ #
133
+ # @return [Array(Integer, Net::HTTPResponse, Array<String>), nil]
134
+ def poll_once(client, orig_url, location, timeout)
135
+ raise Turbopuffer::Errors::APITimeoutError.new(url: orig_url) if timeout.remaining.zero?
136
+
137
+ body =
138
+ begin
139
+ client.request(
140
+ method: :get,
141
+ path: location.request_uri,
142
+ model: Turbopuffer::Internal::Type::Unknown,
143
+ options: {
144
+ extra_headers: {HEADER_PREFER => ""},
145
+ timeout: timeout.poll_timeout
146
+ }
147
+ )
148
+ rescue JSON::ParserError => e
149
+ raise Turbopuffer::Errors::APIError.new(
150
+ url: orig_url,
151
+ message: "malformed poll response: #{e.message}"
152
+ )
153
+ end
154
+
155
+ case body
156
+ in {status: "running"}
157
+ sleep(timeout.sleep_duration)
158
+ nil
159
+ in {status: "finished", result: {success: success}}
160
+ response = Net::HTTPOK.new("1.1", 200, "OK")
161
+ response["content-type"] = "application/json"
162
+ [200, response, [JSON.generate(success)]]
163
+ in {status: "finished", result: {error: {status_code: Integer => err_status, **rest}}}
164
+ raise Turbopuffer::Errors::APIStatusError.for(
165
+ url: orig_url,
166
+ status: err_status,
167
+ headers: nil,
168
+ body: rest[:detail],
169
+ request: nil,
170
+ response: nil
171
+ )
172
+ else
173
+ raise Turbopuffer::Errors::APIError.new(
174
+ url: orig_url,
175
+ message: "malformed poll response: #{body.inspect}"
176
+ )
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -309,6 +309,8 @@ module Turbopuffer
309
309
  headers["x-stainless-timeout"] = timeout.to_s
310
310
  end
311
311
 
312
+ Turbopuffer::Internal::RespondAsync.prepare_headers(headers)
313
+
312
314
  headers.reject! { |_, v| v.to_s.empty? }
313
315
 
314
316
  body =
@@ -501,12 +503,18 @@ module Turbopuffer
501
503
 
502
504
  # Don't send the current retry count in the headers if the caller modified the header defaults.
503
505
  send_retry_header = request.fetch(:headers)["x-stainless-retry-count"] == "0"
504
- status, response, stream = send_request(
506
+ _, response, stream = send_request(
505
507
  request,
506
508
  redirect_count: 0,
507
509
  retry_count: 0,
508
510
  send_retry_header: send_retry_header
509
511
  )
512
+ status, response, stream = Turbopuffer::Internal::RespondAsync.maybe_poll(
513
+ self,
514
+ request,
515
+ response,
516
+ stream
517
+ )
510
518
 
511
519
  headers = Turbopuffer::Internal::Util.normalized_headers(response.each_header.to_h)
512
520
  decoded = Turbopuffer::Internal::Util.decode_content(headers, stream: stream)
@@ -59,7 +59,7 @@ module Turbopuffer
59
59
 
60
60
  # @!attribute tokenizer
61
61
  # The tokenizer to use for full-text search on an attribute. Defaults to
62
- # `word_v3`.
62
+ # `word_v4`.
63
63
  #
64
64
  # @return [Symbol, Turbopuffer::Models::Tokenizer, nil]
65
65
  optional :tokenizer, enum: -> { Turbopuffer::Tokenizer }
@@ -86,7 +86,7 @@ module Turbopuffer
86
86
  #
87
87
  # @param stemming [Boolean] Language-specific stemming for the text. Defaults to `false` (i.e., do not stem)
88
88
  #
89
- # @param tokenizer [Symbol, Turbopuffer::Models::Tokenizer] The tokenizer to use for full-text search on an attribute. Defaults to `word_v3`
89
+ # @param tokenizer [Symbol, Turbopuffer::Models::Tokenizer] The tokenizer to use for full-text search on an attribute. Defaults to `word_v4`
90
90
  end
91
91
  end
92
92
  end
@@ -11,13 +11,22 @@ module Turbopuffer
11
11
  required :max_edit_distance,
12
12
  -> { Turbopuffer::Internal::Type::ArrayOf[Turbopuffer::FuzzyMaxEditDistance] }
13
13
 
14
- # @!method initialize(max_edit_distance:)
14
+ # @!attribute case_sensitive
15
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
16
+ # case-sensitive).
17
+ #
18
+ # @return [Boolean, nil]
19
+ optional :case_sensitive, Turbopuffer::Internal::Type::Boolean
20
+
21
+ # @!method initialize(max_edit_distance:, case_sensitive: nil)
15
22
  # Some parameter documentations has been truncated, see
16
23
  # {Turbopuffer::Models::FuzzyParams} for more details.
17
24
  #
18
25
  # Additional parameters for the Fuzzy filter.
19
26
  #
20
27
  # @param max_edit_distance [Array<Turbopuffer::Models::FuzzyMaxEditDistance>] Maximum edit distance allowed at each query length. Queries shorter than the fir
28
+ #
29
+ # @param case_sensitive [Boolean] Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
21
30
  end
22
31
  end
23
32
  end
@@ -3,7 +3,7 @@
3
3
  module Turbopuffer
4
4
  module Models
5
5
  # The tokenizer to use for full-text search on an attribute. Defaults to
6
- # `word_v3`.
6
+ # `word_v4`.
7
7
  module Tokenizer
8
8
  extend Turbopuffer::Internal::Type::Enum
9
9
 
@@ -12,6 +12,7 @@ module Turbopuffer
12
12
  WORD_V1 = :word_v1
13
13
  WORD_V2 = :word_v2
14
14
  WORD_V3 = :word_v3
15
+ WORD_V4 = :word_v4
15
16
 
16
17
  # @!method self.values
17
18
  # @return [Array<Symbol>]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Turbopuffer
4
- VERSION = "2.1.0.pre.alpha.1"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/turbopuffer.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "turbopuffer/file_part"
51
51
  require_relative "turbopuffer/errors"
52
52
  require_relative "turbopuffer/internal/transport/base_client"
53
53
  require_relative "turbopuffer/internal/transport/pooled_net_requester"
54
+ require_relative "turbopuffer/internal/respond_async"
54
55
  require_relative "turbopuffer/client"
55
56
  require_relative "turbopuffer/internal/namespace_page"
56
57
  require_relative "turbopuffer/models/pinning_config"
@@ -74,7 +74,7 @@ module Turbopuffer
74
74
  attr_writer :stemming
75
75
 
76
76
  # The tokenizer to use for full-text search on an attribute. Defaults to
77
- # `word_v3`.
77
+ # `word_v4`.
78
78
  sig { returns(T.nilable(Turbopuffer::Tokenizer::OrSymbol)) }
79
79
  attr_reader :tokenizer
80
80
 
@@ -119,7 +119,7 @@ module Turbopuffer
119
119
  # stem).
120
120
  stemming: nil,
121
121
  # The tokenizer to use for full-text search on an attribute. Defaults to
122
- # `word_v3`.
122
+ # `word_v4`.
123
123
  tokenizer: nil
124
124
  )
125
125
  end
@@ -13,22 +13,38 @@ module Turbopuffer
13
13
  sig { returns(T::Array[Turbopuffer::FuzzyMaxEditDistance]) }
14
14
  attr_accessor :max_edit_distance
15
15
 
16
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
17
+ # case-sensitive).
18
+ sig { returns(T.nilable(T::Boolean)) }
19
+ attr_reader :case_sensitive
20
+
21
+ sig { params(case_sensitive: T::Boolean).void }
22
+ attr_writer :case_sensitive
23
+
16
24
  # Additional parameters for the Fuzzy filter.
17
25
  sig do
18
26
  params(
19
- max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance::OrHash]
27
+ max_edit_distance:
28
+ T::Array[Turbopuffer::FuzzyMaxEditDistance::OrHash],
29
+ case_sensitive: T::Boolean
20
30
  ).returns(T.attached_class)
21
31
  end
22
32
  def self.new(
23
33
  # Maximum edit distance allowed at each query length. Queries shorter than the
24
34
  # first threshold return no matches.
25
- max_edit_distance:
35
+ max_edit_distance:,
36
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
37
+ # case-sensitive).
38
+ case_sensitive: nil
26
39
  )
27
40
  end
28
41
 
29
42
  sig do
30
43
  override.returns(
31
- { max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance] }
44
+ {
45
+ max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance],
46
+ case_sensitive: T::Boolean
47
+ }
32
48
  )
33
49
  end
34
50
  def to_hash
@@ -3,7 +3,7 @@
3
3
  module Turbopuffer
4
4
  module Models
5
5
  # The tokenizer to use for full-text search on an attribute. Defaults to
6
- # `word_v3`.
6
+ # `word_v4`.
7
7
  module Tokenizer
8
8
  extend Turbopuffer::Internal::Type::Enum
9
9
 
@@ -16,6 +16,7 @@ module Turbopuffer
16
16
  WORD_V1 = T.let(:word_v1, Turbopuffer::Tokenizer::TaggedSymbol)
17
17
  WORD_V2 = T.let(:word_v2, Turbopuffer::Tokenizer::TaggedSymbol)
18
18
  WORD_V3 = T.let(:word_v3, Turbopuffer::Tokenizer::TaggedSymbol)
19
+ WORD_V4 = T.let(:word_v4, Turbopuffer::Tokenizer::TaggedSymbol)
19
20
 
20
21
  sig { override.returns(T::Array[Turbopuffer::Tokenizer::TaggedSymbol]) }
21
22
  def self.values
@@ -1,17 +1,26 @@
1
1
  module Turbopuffer
2
2
  module Models
3
3
  type fuzzy_params =
4
- { max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance] }
4
+ {
5
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
6
+ case_sensitive: bool
7
+ }
5
8
 
6
9
  class FuzzyParams < Turbopuffer::Internal::Type::BaseModel
7
10
  attr_accessor max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
8
11
 
12
+ attr_reader case_sensitive: bool?
13
+
14
+ def case_sensitive=: (bool) -> bool
15
+
9
16
  def initialize: (
10
- max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
17
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
18
+ ?case_sensitive: bool
11
19
  ) -> void
12
20
 
13
21
  def to_hash: -> {
14
- max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
22
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
23
+ case_sensitive: bool
15
24
  }
16
25
  end
17
26
  end
@@ -1,7 +1,12 @@
1
1
  module Turbopuffer
2
2
  module Models
3
3
  type tokenizer =
4
- :pre_tokenized_array | :word_v0 | :word_v1 | :word_v2 | :word_v3
4
+ :pre_tokenized_array
5
+ | :word_v0
6
+ | :word_v1
7
+ | :word_v2
8
+ | :word_v3
9
+ | :word_v4
5
10
 
6
11
  module Tokenizer
7
12
  extend Turbopuffer::Internal::Type::Enum
@@ -11,6 +16,7 @@ module Turbopuffer
11
16
  WORD_V1: :word_v1
12
17
  WORD_V2: :word_v2
13
18
  WORD_V3: :word_v3
19
+ WORD_V4: :word_v4
14
20
 
15
21
  def self?.values: -> ::Array[Turbopuffer::Models::tokenizer]
16
22
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbopuffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0.pre.alpha.1
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Turbopuffer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-02 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -55,6 +55,7 @@ files:
55
55
  - lib/turbopuffer/file_part.rb
56
56
  - lib/turbopuffer/internal.rb
57
57
  - lib/turbopuffer/internal/namespace_page.rb
58
+ - lib/turbopuffer/internal/respond_async.rb
58
59
  - lib/turbopuffer/internal/transport/base_client.rb
59
60
  - lib/turbopuffer/internal/transport/pooled_net_requester.rb
60
61
  - lib/turbopuffer/internal/type/array_of.rb
@@ -332,9 +333,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
332
333
  version: 3.2.0
333
334
  required_rubygems_version: !ruby/object:Gem::Requirement
334
335
  requirements:
335
- - - ">"
336
+ - - ">="
336
337
  - !ruby/object:Gem::Version
337
- version: 1.3.1
338
+ version: '0'
338
339
  requirements: []
339
340
  rubygems_version: 3.4.1
340
341
  signing_key: