activerecord-turbopuffer-adapter 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89fff3a49b190acd9a305bcd7be04cf0cc5c4880c1c3043ce51925c7a4dafee3
4
+ data.tar.gz: c03353b5f1b93e25c54b8b70dd3da248beb56aaf8b018b797c1b0aa0a6721f16
5
+ SHA512:
6
+ metadata.gz: baf2d0855805fb1b4e42839a92221c8983af5d372370a9b2ae86885748716aed1b2c91f4e0c5f6e99afd5c4b20bca172ec3c81464eda9d428723eac07d257926
7
+ data.tar.gz: 02faeb7bde3748592e604d5fc568e134918a509301b116bbada20cc3f0fa0afb951a7fb247d26607987175eb0539d2d73c771924e584052cd10c52c7608f1c1f
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Richard Monette
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # activerecord-turbopuffer-adapter
2
+
3
+ [![CI](https://github.com/richardmonette/activerecord-turbopuffer-adapter/actions/workflows/ci.yml/badge.svg)](https://github.com/richardmonette/activerecord-turbopuffer-adapter/actions/workflows/ci.yml)
4
+
5
+ activerecord-turbopuffer-adapter is an unofficial, fan made Ruby on Rails ActiveRecord database adapter for turbopuffer. If you are looking for the official turbopuffer Ruby gem see: https://github.com/turbopuffer/turbopuffer-ruby
6
+
7
+ The purpose of this gem is to provide Rails developers a familiar ActiveRecord style interface to turbopuffer.
8
+
9
+ ## Installation
10
+
11
+ To use this gem, install via Bundler by adding the following to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'activerecord-turbopuffer-adapter'
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Configuration
20
+
21
+ In `config/database.yml` define a turbopuffer adapter:
22
+
23
+
24
+ ```yaml
25
+ development:
26
+ adapter: turbopuffer
27
+ region: gcp-us-central1
28
+ api_key: <%= ENV["TURBOPUFFER_API_KEY"] %>
29
+ database_tasks: false
30
+ ```
31
+
32
+ Optionally, you can define `namespace_prefix`, which is useful for separating namespaces for production and development environments.
33
+
34
+ ### Defining a model
35
+
36
+ ```ruby
37
+ class Document < ApplicationRecord
38
+ turbopuffer_attribute "id", "uuid", not_null: 1
39
+ turbopuffer_attribute "title", "string", filterable: true
40
+ turbopuffer_attribute "body", "string", full_text_search: true
41
+ turbopuffer_attribute "published", "bool", filterable: true
42
+ turbopuffer_attribute "embedding", "[1536]f32", ann: true
43
+ end
44
+ ```
45
+
46
+ By default the namespace is the model's table_name, but it can be customized with `self.table_name = "..."` Currently, ids are always UUIDv7 (which sort chronologically, to support pagination.)
47
+
48
+ The distance metric applies to all vector columns in a namespace and defaults to `cosine_distance`. To use `euclidean_squared` instead, declare it in the model:
49
+
50
+ ```ruby
51
+ class Document < ApplicationRecord
52
+ turbopuffer_distance_metric "euclidean_squared"
53
+
54
+ turbopuffer_attribute "id", "uuid", not_null: 1
55
+ turbopuffer_attribute "embedding", "[1536]f32", ann: true
56
+ end
57
+ ```
58
+
59
+ ### Creating records
60
+
61
+ ```ruby
62
+ Document.create!(title: "Hello", body: "...", published: true)
63
+
64
+ doc = Document.new(title: "Draft")
65
+ doc.save!
66
+
67
+ doc.update!(published: true)
68
+ doc.destroy
69
+ ```
70
+
71
+ > Note that inserts are treated as upserts, such that writing a row whose id already exists is effectively treated as an update.
72
+
73
+ > Note that transactions are not supported, interacting with that portion of the ActiveRecord API is no-op
74
+
75
+ To avoid N+1s you can use insert_all/upsert_all.
76
+
77
+ ```ruby
78
+ Document.insert_all(
79
+ documents.map { |doc| { title: doc.title, body: doc.body, body_embedding: doc.embedding } }
80
+ )
81
+ ```
82
+
83
+ ### Querying
84
+
85
+ ```ruby
86
+ Document.where(published: true)
87
+ Document.where(id: ["a", "b"])
88
+ Document.where.not(id: ["a", "b"])
89
+ Document.where(created_at: 1.week.ago..)
90
+
91
+ Document.where(title: /^walrus/i)
92
+ Document.where(title: Document.glob("walrus*"))
93
+
94
+ Document.where(tags: "walrus")
95
+ Document.where(tags: ["walrus", "narwhal"])
96
+ Document.where.not(tags: "walrus")
97
+ Document.where(tags: nil)
98
+ Document.where(scores: 90..)
99
+
100
+ Document.order(:title).limit(20)
101
+ Document.group(:title).count
102
+ Document.count
103
+ Document.find("018f...")
104
+
105
+ Document.rank_by("vector", "ANN", query_vector).limit(10)
106
+ Document.rank_by("text", "BM25", "quick walrus").limit(10)
107
+
108
+ Document
109
+ .where(published: true)
110
+ .rank_by(["Sum", [
111
+ ["Product", 2, ["category", "BM25", "mammal"]],
112
+ ["text", "BM25", "quick walrus"],
113
+ ]])
114
+ .limit(10)
115
+ ```
116
+
117
+ > Note that turbopuffer has a limit on the maximum number of documents returned (https://turbopuffer.com/docs/query#param-limit), so Document.all.to_a etc. will only return at most 10,000 items
118
+
119
+ ### Using alongside Postgres
120
+
121
+ While something of a lark, aspirationally the idea of this gem is to make turbopuffer conveniently usable as the primary db in a Rails app. In practice, however, using turbopuffer alongside a traditional primary db (such as postgres) is a supported, potentially more practical solution.
122
+
123
+ A dual db approach can be setup as follows:
124
+
125
+ ```yaml
126
+ development:
127
+ primary:
128
+ adapter: postgresql
129
+ database: myapp_development
130
+ turbopuffer:
131
+ adapter: turbopuffer
132
+ region: gcp-us-central1
133
+ api_key: <%= ENV["TURBOPUFFER_API_KEY"] %>
134
+ namespace_prefix: myapp-development
135
+ database_tasks: false
136
+ ```
137
+
138
+ ```ruby
139
+ class TurbopufferRecord < ApplicationRecord
140
+ self.abstract_class = true
141
+
142
+ connects_to database: { writing: :turbopuffer, reading: :turbopuffer }
143
+ end
144
+
145
+ class Document < TurbopufferRecord
146
+ turbopuffer_attribute "id", "uuid", not_null: 1
147
+ turbopuffer_attribute "body", "string", full_text_search: true
148
+ end
149
+ ```
150
+
151
+ ## Contributing
152
+
153
+ Bug reports and pull requests are welcome on GitHub at https://github.com/richardmonette/activerecord-turbopuffer-adapter. As this is an unofficial gem, please do not report bugs upstream.
154
+
155
+ ## License
156
+
157
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "turbopuffer"
4
+
5
+ require "arel/visitors/turbopuffer"
6
+ require "turbopuffer/active_record/schema"
7
+ require "turbopuffer/active_record/type"
8
+
9
+ module ActiveRecord
10
+ module ConnectionAdapters
11
+ class CachedQuery < ActiveRecord::StatementCache::Query
12
+ def initialize(ast, visitor)
13
+ @ast, @visitor, @retryable = ast, visitor, true
14
+ end
15
+
16
+ def sql_for(binds, connection)
17
+ @visitor.compile(@ast, bound_values: binds).first
18
+ end
19
+ end
20
+
21
+ class TurbopufferAdapter < AbstractAdapter
22
+ ADAPTER_NAME = "Turbopuffer"
23
+
24
+ class << self
25
+ private
26
+
27
+ def initialize_type_map(m)
28
+ m.register_type "string", Type::String.new
29
+ m.register_type "uuid", Type::String.new
30
+ m.register_type "int", Type::Integer.new(limit: 8)
31
+ m.register_type "uint", ::Turbopuffer::ActiveRecord::Type::UnsignedInteger.new(limit: 8)
32
+ m.register_type "float", Type::Float.new
33
+ m.register_type "bool", Type::Boolean.new
34
+ m.register_type "datetime", ::Turbopuffer::ActiveRecord::Type::DateTime.new
35
+ m.register_type(%r{\A\[\].+\z}) do |type|
36
+ ::Turbopuffer::ActiveRecord::Type::Array.new(m.lookup(type.delete_prefix("[]")))
37
+ end
38
+ m.register_type(%r{\A\[\d+\]f(?:16|32)\z}) do |type|
39
+ ::Turbopuffer::ActiveRecord::Type::Vector.new(type[/\d+/].to_i)
40
+ end
41
+ end
42
+ end
43
+
44
+ TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) }
45
+ EXTENDED_TYPE_MAPS = Concurrent::Map.new
46
+
47
+ def column_definitions(table_name)
48
+ ::Turbopuffer::ActiveRecord::Schema::Model.for_table(table_name).turbopuffer_attributes
49
+ end
50
+
51
+ def tables
52
+ ::Turbopuffer::ActiveRecord::Schema::Model.namespaces.map(&:table_name).uniq
53
+ end
54
+ def views = []
55
+ def data_sources = tables
56
+ def table_exists?(name) = tables.include?(name.to_s)
57
+ def data_source_exists?(name) = table_exists?(name)
58
+
59
+ def supports_insert_on_duplicate_skip? = true
60
+ def supports_insert_on_duplicate_update? = true
61
+
62
+ def supports_savepoints? = false
63
+ def supports_ddl_transactions? = false
64
+ def supports_transaction_isolation? = false
65
+ def supports_lazy_transactions? = false
66
+ def supports_restart_db_transaction? = false
67
+ def supports_advisory_locks? = false
68
+
69
+ def begin_db_transaction = nil
70
+ def commit_db_transaction = nil
71
+ def exec_rollback_db_transaction = nil
72
+ def create_savepoint(name = nil) = nil
73
+ def exec_rollback_to_savepoint(name = nil) = nil
74
+ def release_savepoint(name = nil) = nil
75
+
76
+ def begin_isolated_db_transaction(isolation)
77
+ raise ActiveRecord::TransactionIsolationError, "Turbopuffer does not support isolation levels"
78
+ end
79
+
80
+ def primary_key(table_name)
81
+ "id"
82
+ end
83
+
84
+ def default_insert_value(column)
85
+ nil
86
+ end
87
+
88
+ def self.quote_column_name(name) = name.to_s
89
+
90
+ def view_exists?(name) = false
91
+
92
+ def write_query?(sql)
93
+ query = sql.is_a?(Array) ? sql.first : sql
94
+ query.op != :select
95
+ end
96
+
97
+ def type_cast(value)
98
+ case value
99
+ when Array
100
+ value
101
+ else
102
+ super
103
+ end
104
+ end
105
+
106
+ def new_column_from_field(table_name, field, definitions)
107
+ Column.new(field.name, lookup_cast_type(field.type), nil, nil, field.notnull.to_i == 0)
108
+ end
109
+
110
+ def build_insert_sql(insert)
111
+ columns = insert.keys_including_timestamps.to_a
112
+ rows, _binds = insert.values_list
113
+
114
+ upsert_rows = rows.map do |row|
115
+ attributes = columns.zip(row).to_h
116
+ attributes["id"] ||= SecureRandom.uuid_v7
117
+ attributes
118
+ end
119
+
120
+ Arel::Visitors::TurbopufferQuery.new(
121
+ op: :insert,
122
+ namespace: insert.model.table_name,
123
+ upsert_rows: upsert_rows
124
+ )
125
+ end
126
+
127
+ def high_precision_current_timestamp
128
+ ::Time.current
129
+ end
130
+
131
+ def cacheable_query(klass, arel)
132
+ _discarded, binds = visitor.compile(arel.ast, collecting_binds: true) # only needed to build the BindMap
133
+ [ CachedQuery.new(arel.ast, visitor), binds ]
134
+ end
135
+
136
+ TurbopufferResult = Struct.new(:fields, :rows, :affected_rows, keyword_init: true) do
137
+ def self.affected(count) = new(fields: [], rows: [], affected_rows: count)
138
+ end
139
+
140
+ def turbopuffer_insert(namespace, query)
141
+ table_name = query.namespace
142
+ model = ::Turbopuffer::ActiveRecord::Schema::Model.for_table(table_name)
143
+
144
+ tpuf_insert_args = {
145
+ upsert_rows: query.upsert_rows,
146
+ schema: model.turbopuffer_schema_hash
147
+ }
148
+
149
+ if model.turbopuffer_attributes.any?(&:ann)
150
+ tpuf_insert_args[:distance_metric] = model.turbopuffer_distance_metric
151
+ end
152
+
153
+ tpuf_result = namespace.write(tpuf_insert_args)
154
+
155
+ fields = column_definitions(table_name).map(&:name)
156
+ rows = query.upsert_rows.map { |r| fields.map { |f| r[f] } }
157
+
158
+ TurbopufferResult.new(fields: fields, rows: rows, affected_rows: tpuf_result.rows_affected)
159
+ end
160
+
161
+ def turbopuffer_update(namespace, query)
162
+ patch = query.upsert_rows.to_h
163
+ pending = pending_patch_filter(patch)
164
+ filters = query.filters ? [ "And", [ query.filters, pending ] ] : pending
165
+
166
+ affected = write_in_batches(
167
+ namespace,
168
+ patch_by_filter: { filters: filters, patch: patch.transform_keys(&:to_sym) },
169
+ patch_by_filter_allow_partial: true
170
+ )
171
+
172
+ TurbopufferResult.affected(affected)
173
+ end
174
+
175
+ def write_in_batches(namespace, args)
176
+ affected = 0
177
+
178
+ loop do
179
+ tpuf_result = namespace.write(args)
180
+ affected += tpuf_result.rows_affected
181
+
182
+ break unless tpuf_result.rows_remaining
183
+
184
+ if tpuf_result.rows_affected.zero?
185
+ raise ActiveRecord::StatementInvalid, "write made no progress: rows still match #{args.inspect}"
186
+ end
187
+ end
188
+
189
+ affected
190
+ end
191
+
192
+ def pending_patch_filter(patch)
193
+ conditions = patch.flat_map do |attribute, value|
194
+ if value.nil?
195
+ [ [ attribute, "NotEq", nil ] ]
196
+ else
197
+ [ [ attribute, "NotEq", value ], [ attribute, "Eq", nil ] ]
198
+ end
199
+ end
200
+
201
+ [ "Or", conditions ]
202
+ end
203
+
204
+ def turbopuffer_delete(namespace, query)
205
+ attribute, operator, value = query.filters
206
+
207
+ affected = if query.filters.nil?
208
+ turbopuffer_delete_namespace(namespace)
209
+ elsif attribute == "id" && operator == "Eq"
210
+ namespace.write(deletes: [ value ]).rows_affected
211
+ elsif attribute == "id" && operator == "In"
212
+ namespace.write(deletes: value).rows_affected
213
+ else
214
+ write_in_batches(namespace, delete_by_filter: query.filters, delete_by_filter_allow_partial: true)
215
+ end
216
+
217
+ TurbopufferResult.affected(affected)
218
+ end
219
+
220
+ def turbopuffer_delete_namespace(namespace)
221
+ count = namespace.query(aggregate_by: { count: [ "Count" ] }).aggregations[:count]
222
+ namespace.delete_all
223
+ count
224
+ rescue Turbopuffer::Errors::NotFoundError
225
+ 0
226
+ end
227
+
228
+ def turbopuffer_aggregate(namespace, query)
229
+ aggregate_alias, aggregate = query.aggregate_by
230
+
231
+ tpuf_query_args = {
232
+ aggregate_by: { aggregate_alias => aggregate }
233
+ }
234
+
235
+ tpuf_query_args[:filters] = query.filters if query.filters
236
+
237
+ if query.group_by.present?
238
+ tpuf_query_args[:group_by] = query.group_by
239
+ tpuf_query_args[:top_k] = query.top_k if query.top_k.present?
240
+ end
241
+
242
+ result = namespace.query(tpuf_query_args)
243
+
244
+ if query.group_by.present?
245
+ fields = query.group_by + [ aggregate_alias ]
246
+ rows = result.aggregation_groups.map(&:to_h).map { |g| fields.map { |f| g[f.to_sym] } }
247
+
248
+ TurbopufferResult.new(fields:, rows:, affected_rows: 0)
249
+ else
250
+ count = result.aggregations[aggregate_alias.to_sym]
251
+
252
+ TurbopufferResult.new(fields: [ aggregate_alias ], rows: [ [ count ] ], affected_rows: 0)
253
+ end
254
+ rescue Turbopuffer::Errors::NotFoundError
255
+ TurbopufferResult.affected(0)
256
+ end
257
+
258
+ def turbopuffer_select(namespace, query)
259
+ fields = if query.include_attributes == [ "*" ]
260
+ column_definitions(query.namespace).map(&:name)
261
+ else
262
+ query.include_attributes
263
+ end
264
+
265
+ tpuf_query_args = {
266
+ include_attributes: fields,
267
+ top_k: query.top_k.present? ? query.top_k : 10_000
268
+ }
269
+
270
+ tpuf_query_args[:rank_by] = query.rank_by.first if query.rank_by.present?
271
+ tpuf_query_args[:filters] = query.filters if query.filters
272
+
273
+ tpuf_result = namespace.query(tpuf_query_args)
274
+
275
+ rows = tpuf_result.rows.map(&:to_h).map { |r| fields.map { |f| r[f.to_sym] } }
276
+
277
+ TurbopufferResult.new(fields:, rows:, affected_rows: 0)
278
+ rescue Turbopuffer::Errors::NotFoundError
279
+ TurbopufferResult.affected(0)
280
+ end
281
+
282
+ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
283
+ tpuf_query = sql.is_a?(Array) ? sql.first : sql
284
+
285
+ namespace_name = [ @config[:namespace_prefix], tpuf_query.namespace ].compact_blank.join("-")
286
+ namespace = raw_connection.namespace(namespace_name)
287
+
288
+ result = case tpuf_query.op
289
+ when :insert then turbopuffer_insert(namespace, tpuf_query)
290
+ when :update then turbopuffer_update(namespace, tpuf_query)
291
+ when :delete then turbopuffer_delete(namespace, tpuf_query)
292
+ when :select
293
+ if tpuf_query.aggregate_by
294
+ turbopuffer_aggregate(namespace, tpuf_query)
295
+ else
296
+ turbopuffer_select(namespace, tpuf_query)
297
+ end
298
+ end
299
+
300
+ notification_payload[:row_count] = result.rows.size
301
+ result
302
+ end
303
+
304
+ def arel_visitor
305
+ Arel::Visitors::Turbopuffer.new
306
+ end
307
+
308
+ def cast_result(result)
309
+ if result.fields.empty?
310
+ ActiveRecord::Result.empty(affected_rows: result.affected_rows)
311
+ else
312
+ ActiveRecord::Result.new(result.fields, result.rows, affected_rows: result.affected_rows)
313
+ end
314
+ end
315
+
316
+ def prefetch_primary_key?(table_name = nil)
317
+ true
318
+ end
319
+
320
+ def next_sequence_value(sequence_name)
321
+ SecureRandom.uuid_v7
322
+ end
323
+
324
+ def affected_rows(result)
325
+ result.affected_rows
326
+ end
327
+
328
+ def get_full_version
329
+ Turbopuffer::VERSION
330
+ end
331
+
332
+ def active?
333
+ connected?
334
+ end
335
+
336
+ def connect
337
+ @raw_connection = Turbopuffer::Client.new(
338
+ region: @config[:region],
339
+ api_key: @config[:api_key]
340
+ )
341
+ end
342
+
343
+ def disconnect!
344
+ @raw_connection = nil
345
+ end
346
+
347
+ def requires_reloading? = false
348
+
349
+ private
350
+
351
+ def reconnect
352
+ connect
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1 @@
1
+ require "turbopuffer/active_record"
@@ -0,0 +1,15 @@
1
+ module Arel::Nodes
2
+ class Glob < Arel::Nodes::Binary
3
+ attr_reader :case_sensitive
4
+
5
+ def initialize(left, right, case_sensitive: true)
6
+ super(left, right)
7
+ @case_sensitive = case_sensitive
8
+ end
9
+
10
+ def hash = [ self.class, left, right, case_sensitive ].hash
11
+
12
+ def eql?(other) = super && case_sensitive == other.case_sensitive
13
+ alias == eql?
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Arel::Nodes
2
+ class RankByNode < Arel::Nodes::Unary
3
+ attr_reader :expression
4
+
5
+ def initialize(*expression)
6
+ @expression = expression.size == 1 ? expression.first : expression
7
+ super(nil)
8
+ end
9
+
10
+ def hash = [ self.class, expression ].hash
11
+
12
+ def eql?(other) = self.class == other.class && expression == other.expression
13
+ alias == eql?
14
+ end
15
+ end
@@ -0,0 +1,280 @@
1
+ module Arel::Visitors
2
+ class TurbopufferQuery
3
+ def to_h
4
+ {
5
+ op: @op,
6
+ namespace: @namespace,
7
+ filters: @filters,
8
+ group_by: @group_by,
9
+ top_k: @top_k,
10
+ rank_by: @rank_by,
11
+ include_attributes: @include_attributes,
12
+ aggregate_by: @aggregate_by,
13
+ upsert_rows: @upsert_rows
14
+ }
15
+ end
16
+ def to_s = JSON.generate(to_h)
17
+ alias inspect to_s
18
+ def hash = to_h.hash
19
+ def eql?(other) = other.is_a?(self.class) && to_h == other.to_h
20
+ alias == eql?
21
+
22
+ attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by
23
+
24
+ def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil,
25
+ include_attributes: nil, upsert_rows: nil, aggregate_by: nil, group_by: nil)
26
+ @op = op
27
+ @namespace = namespace
28
+ @filters = filters
29
+ @top_k = top_k
30
+ @rank_by = rank_by
31
+ @include_attributes = include_attributes
32
+ @aggregate_by = aggregate_by
33
+ @group_by = group_by
34
+ @upsert_rows = upsert_rows
35
+ end
36
+ end
37
+
38
+ class Turbopuffer < Arel::Visitors::Visitor
39
+ def initialize
40
+ super
41
+ @binds = []
42
+ end
43
+
44
+ attr_accessor :binds
45
+
46
+ def compile(node, _collector = nil, bound_values: nil, collecting_binds: false)
47
+ @binds = []
48
+ @bound_values = bound_values
49
+ @collecting_binds = collecting_binds
50
+ [ visit(node), @binds ]
51
+ ensure
52
+ @bound_values = nil
53
+ @collecting_binds = false
54
+ end
55
+
56
+ private
57
+
58
+ def conjoin(filters)
59
+ case filters.size
60
+ when 0 then nil
61
+ when 1 then filters.first
62
+ else [ "And", filters ]
63
+ end
64
+ end
65
+
66
+ def aggregate_node(projection)
67
+ projection.is_a?(Arel::Nodes::As) ? projection.left : projection
68
+ end
69
+
70
+ def aggregate?(projection)
71
+ aggregate_node(projection).is_a?(Arel::Nodes::Function)
72
+ end
73
+
74
+ def counted_attribute(projection)
75
+ node = aggregate_node(projection)
76
+ return unless node.is_a?(Arel::Nodes::Count)
77
+
78
+ expression = node.expressions.first
79
+ expression if expression.is_a?(Arel::Attributes::Attribute)
80
+ end
81
+
82
+ def aggregate_for(o)
83
+ raise NotImplementedError, "distinct is not implemented yet" if o.distinct
84
+
85
+ case o
86
+ when Arel::Nodes::Count then [ "Count" ]
87
+ when Arel::Nodes::Sum then [ "Sum", visit(o.expressions.first) ]
88
+ else
89
+ raise NotImplementedError, "#{o.class.name.demodulize} is not supported, turbopuffer aggregates are Count and Sum"
90
+ end
91
+ end
92
+
93
+ def visit_Arel_Nodes_SelectStatement(o)
94
+ raise NotImplementedError, "offset is not supported, filter on a sortable attribute for cursor pagination" if o.offset
95
+ # https://turbopuffer.com/docs/query#ordering-by-attributes: "Ordering by
96
+ # multiple attributes isn't yet implemented."
97
+ raise NotImplementedError, "one ranking per query, sort in Ruby after loading" if o.orders.size > 1
98
+
99
+ core = o.cores.last
100
+
101
+ raise NotImplementedError, "distinct is not implemented yet" if core.set_quantifier
102
+ raise NotImplementedError, "having is not implemented yet" if core.havings.any?
103
+
104
+ aggregates, attributes = core.projections.partition { |p| aggregate?(p) }
105
+ aggregate = aggregates.first
106
+
107
+ filters = core.wheres.map { |w| visit(w) }
108
+ counted = aggregate && counted_attribute(aggregate)
109
+ filters << [ visit(counted), "NotEq", nil ] if counted
110
+
111
+ TurbopufferQuery.new(
112
+ op: :select,
113
+ namespace: visit(core.source),
114
+ filters: conjoin(filters),
115
+ top_k: o.limit && visit(o.limit),
116
+ rank_by: o.orders.map { |ord| visit(ord) },
117
+ include_attributes: attributes.flat_map { |p| visit(p) },
118
+ group_by: core.groups.map { |g| visit(g) },
119
+ aggregate_by: aggregate && visit(aggregate)
120
+ )
121
+ end
122
+
123
+ def visit_Arel_Nodes_InsertStatement(o)
124
+ raise NotImplementedError, "INSERT ... SELECT" if o.select
125
+
126
+ columns = o.columns.map { |c| visit(c) }
127
+ rows = o.values ? visit(o.values) : [ [] ]
128
+
129
+ TurbopufferQuery.new(
130
+ op: :insert,
131
+ namespace: visit(o.relation),
132
+ upsert_rows: rows.map { |row| columns.zip(row).to_h }
133
+ )
134
+ end
135
+
136
+ def visit_Arel_Nodes_UpdateStatement(o)
137
+ rows = o.values ? visit(o.values) : [ [] ]
138
+
139
+ TurbopufferQuery.new(
140
+ op: :update,
141
+ namespace: visit(o.relation),
142
+ filters: conjoin(o.wheres.map { |w| visit(w) }),
143
+ upsert_rows: rows
144
+ )
145
+ end
146
+
147
+ def visit_Arel_Nodes_DeleteStatement(o)
148
+ TurbopufferQuery.new(
149
+ op: :delete,
150
+ namespace: visit(o.relation),
151
+ filters: conjoin(o.wheres.map { |w| visit(w) })
152
+ )
153
+ end
154
+
155
+ def visit_ActiveModel_Attribute(o)
156
+ @binds << o
157
+
158
+ attribute = @bound_values ? @bound_values[@binds.size - 1] : o
159
+
160
+ if ActiveRecord::StatementCache::Substitute === attribute.value_before_type_cast
161
+ return nil if @collecting_binds
162
+ raise "unbound statement-cache placeholder for #{o.name.inspect}"
163
+ end
164
+
165
+ attribute.value_for_database
166
+ end
167
+ alias visit_ActiveRecord_Relation_QueryAttribute visit_ActiveModel_Attribute
168
+
169
+ def visit_Arel_Nodes_BindParam(o)
170
+ visit(o.value)
171
+ end
172
+
173
+ def visit_Arel_Nodes_ValuesList(o)
174
+ o.rows.map { |row| row.map { |v| visit(v) } }
175
+ end
176
+
177
+ def visit_Arel_Nodes_JoinSource(o)
178
+ raise NotImplementedError, "turbopuffer has no joins" if o.right.any?
179
+ visit(o.left)
180
+ end
181
+
182
+ def visit_Arel_Table(o) = o.name
183
+
184
+ # filters
185
+ def visit_Arel_Nodes_And(o) = [ "And", o.children.map { |c| visit(c) } ]
186
+ def visit_Arel_Nodes_Or(o) = [ "Or", [ visit(o.left), visit(o.right) ] ]
187
+ def visit_Arel_Nodes_Grouping(o) = visit(o.expr)
188
+
189
+ def visit_Arel_Nodes_UnqualifiedColumn(o)
190
+ visit o.expr
191
+ end
192
+
193
+ def visit_Arel_Nodes_Assignment(o)
194
+ [ visit(o.left), visit(o.right) ]
195
+ end
196
+
197
+ ARRAY_OPERATORS = {
198
+ "Eq" => "Contains", "NotEq" => "NotContains",
199
+ "In" => "ContainsAny", "NotIn" => "NotContainsAny",
200
+ "Lt" => "AnyLt", "Lte" => "AnyLte", "Gt" => "AnyGt", "Gte" => "AnyGte"
201
+ }.freeze
202
+
203
+ def comparison(node, operator, value = visit(node.right))
204
+ operator = ARRAY_OPERATORS.fetch(operator) if array_attribute?(node.left) && !value.nil?
205
+
206
+ [ visit(node.left), operator, value ]
207
+ end
208
+
209
+ def array_attribute?(node)
210
+ node.is_a?(Arel::Attributes::Attribute) &&
211
+ node.able_to_type_cast? &&
212
+ node.type_caster.is_a?(::Turbopuffer::ActiveRecord::Type::Array)
213
+ end
214
+
215
+ def visit_Arel_Nodes_Equality(o) = comparison(o, "Eq")
216
+ def visit_Arel_Nodes_NotEqual(o) = comparison(o, "NotEq")
217
+ def visit_Arel_Nodes_GreaterThan(o) = comparison(o, "Gt")
218
+ def visit_Arel_Nodes_GreaterThanOrEqual(o) = comparison(o, "Gte")
219
+ def visit_Arel_Nodes_LessThan(o) = comparison(o, "Lt")
220
+ def visit_Arel_Nodes_LessThanOrEqual(o) = comparison(o, "Lte")
221
+ def visit_Arel_Nodes_In(o) = comparison(o, "In")
222
+
223
+ def visit_Arel_Nodes_HomogeneousIn(o)
224
+ comparison(o, o.type == :in ? "In" : "NotIn", o.casted_values)
225
+ end
226
+
227
+ def visit_Arel_Nodes_Between(o)
228
+ low, high = o.right.children.map { |bound| visit(bound) }
229
+
230
+ [ "And", [ comparison(o, "Gte", low), comparison(o, "Lte", high) ] ]
231
+ end
232
+
233
+ def visit_Arel_Nodes_Not(o) = [ "Not", visit(o.expr) ]
234
+
235
+ def visit_Arel_Nodes_Regexp(o)
236
+ pattern = visit(o.right)
237
+
238
+ [ visit(o.left), "Regex", o.case_sensitive ? pattern : "(?i)#{pattern}" ]
239
+ end
240
+
241
+ def visit_Arel_Nodes_NotRegexp(o) = [ "Not", visit_Arel_Nodes_Regexp(o) ]
242
+
243
+ def visit_Arel_Nodes_Glob(o) = [ visit(o.left), o.case_sensitive ? "Glob" : "IGlob", visit(o.right) ]
244
+
245
+ def visit_Arel_Nodes_Count(o) = [ "count_all", aggregate_for(o) ]
246
+ def visit_Arel_Nodes_Sum(o) = [ "sum_#{visit(o.expressions.first)}", aggregate_for(o) ]
247
+ def visit_Arel_Nodes_Function(o) = aggregate_for(o)
248
+
249
+ def visit_Arel_Nodes_As(o)
250
+ aggregate?(o) ? [ o.right.to_s, aggregate_for(o.left) ] : visit(o.left)
251
+ end
252
+
253
+ def visit_Arel_Nodes_Group(o) = visit(o.expr)
254
+
255
+ # leaves — this is where binds get resolved
256
+ def visit_Arel_Attributes_Attribute(o) = o.name.to_s
257
+ def visit_Arel_Nodes_Casted(o) = o.value_for_database
258
+ def visit_Arel_Nodes_Quoted(o) = o.expr
259
+ def visit_Arel_Nodes_Limit(o) = visit(o.expr)
260
+ def visit_Arel_Nodes_Ascending(o) = [ visit(o.expr), "asc" ]
261
+ def visit_Arel_Nodes_Descending(o) = [ visit(o.expr), "desc" ]
262
+
263
+ def visit_Arel_Nodes_RankByNode(o) = o.expression
264
+
265
+ def visit_Arel_Nodes_SqlLiteral(o)
266
+ return "id" if o == ActiveRecord::FinderMethods::ONE_AS_ONE
267
+
268
+ raise NotImplementedError, "raw SQL is not supported: #{o}"
269
+ end
270
+ def visit_Arel_Nodes_BoundSqlLiteral(o) = raise(NotImplementedError, "raw SQL is not supported: #{o.sql_with_placeholders}")
271
+
272
+ def visit_Array(o) = o.map { |x| visit(x) }
273
+ def visit_Integer(o) = o
274
+ def visit_Float(o) = o
275
+ def visit_String(o) = o
276
+ def visit_TrueClass(o) = o
277
+ def visit_FalseClass(o) = o
278
+ def visit_NilClass(_) = nil
279
+ end
280
+ end
@@ -0,0 +1,12 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ class Glob
4
+ attr_reader :pattern, :case_sensitive
5
+
6
+ def initialize(pattern, case_sensitive: true)
7
+ @pattern = pattern
8
+ @case_sensitive = case_sensitive
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ require "rails/railtie"
2
+
3
+ module Turbopuffer
4
+ module ActiveRecord
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "turbopuffer.register_adapter", before: "active_record.initialize_database" do
7
+ ::Turbopuffer::ActiveRecord.register_adapter!
8
+ end
9
+
10
+ initializer "turbopuffer.model_extensions" do
11
+ ActiveSupport.on_load(:active_record) do
12
+ require "turbopuffer/active_record/schema"
13
+
14
+ include ::Turbopuffer::ActiveRecord::Schema::Model
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Schema
4
+ class Attribute
5
+ TYPES = %r{\A(?:\[\])?(?:string|uuid|int|uint|float|bool|datetime)\z|\A\[\d+\]f(?:16|32)\z}
6
+
7
+ attr_reader :name, :type, :filterable, :full_text_search, :ann, :glob, :regex, :notnull
8
+
9
+ def initialize(name, type, not_null: 0, filterable: false, full_text_search: false,
10
+ ann: false, glob: false, regex: false)
11
+ type = type.to_s
12
+
13
+ unless TYPES.match?(type)
14
+ raise ArgumentError, "unknown turbopuffer type #{type.inspect} for attribute #{name.inspect}"
15
+ end
16
+
17
+ @name = name
18
+ @type = type
19
+ @filterable = filterable
20
+ @full_text_search = full_text_search
21
+ @ann = ann
22
+ @glob = glob
23
+ @regex = regex
24
+
25
+ @notnull = not_null
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,101 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Schema
4
+ module Model
5
+ extend ActiveSupport::Concern
6
+
7
+ DISTANCE_METRICS = [ "cosine_distance", "euclidean_squared" ].freeze
8
+
9
+ included do
10
+ class_attribute :turbopuffer_attributes, instance_accessor: false, default: [].freeze
11
+ class_attribute :_turbopuffer_distance_metric, instance_accessor: false, default: "cosine_distance"
12
+ end
13
+
14
+ class << self
15
+ def namespaces
16
+ ::ActiveRecord::Base.descendants.select do |model|
17
+ model.respond_to?(:turbopuffer_namespace?) &&
18
+ !model.abstract_class? &&
19
+ model.turbopuffer_namespace?
20
+ end
21
+ end
22
+
23
+ def for_table(table_name)
24
+ model = namespaces.find { |m| m.table_name == table_name }
25
+
26
+ unless model
27
+ raise UnknownNamespace,
28
+ "No turbopuffer namespace registered for #{table_name.inspect}. " \
29
+ "Declare attributes in the model with `turbopuffer_attribute`. " \
30
+ "Registered: #{namespaces.map(&:table_name).sort.join(", ")}"
31
+ end
32
+
33
+ model
34
+ end
35
+ end
36
+
37
+ class_methods do
38
+ def turbopuffer_attribute(name, type, not_null: 0, filterable: false,
39
+ full_text_search: false, ann: false, glob: false, regex: false)
40
+ attribute = ::Turbopuffer::ActiveRecord::Schema::Attribute.new(
41
+ name.to_s, type, not_null:, filterable:, full_text_search:, ann:, glob:, regex:
42
+ )
43
+
44
+ self.turbopuffer_attributes =
45
+ (turbopuffer_attributes.reject { |a| a.name == attribute.name } + [ attribute ]).freeze
46
+
47
+ attribute
48
+ end
49
+
50
+ def turbopuffer_distance_metric(value = nil)
51
+ return _turbopuffer_distance_metric if value.nil?
52
+
53
+ unless DISTANCE_METRICS.include?(value)
54
+ raise ArgumentError,
55
+ "distance metric must be one of #{DISTANCE_METRICS.join(", ")}, got #{value.inspect}"
56
+ end
57
+
58
+ self._turbopuffer_distance_metric = value
59
+ end
60
+
61
+ def rank_by(*expression)
62
+ expression = expression.first if expression.size == 1
63
+ order(::Arel::Nodes::RankByNode.new(expression))
64
+ end
65
+
66
+ def glob(pattern, case_sensitive: true)
67
+ ::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
68
+ end
69
+
70
+ def predicate_builder
71
+ @predicate_builder ||= super.tap do |builder|
72
+ builder.register_handler(::Regexp, lambda { |attribute, regexp|
73
+ ::Arel::Nodes::Regexp.new(attribute, ::Arel::Nodes.build_quoted(regexp.source), !regexp.casefold?)
74
+ })
75
+
76
+ builder.register_handler(::Turbopuffer::ActiveRecord::Glob, lambda { |attribute, glob|
77
+ ::Arel::Nodes::Glob.new(attribute, ::Arel::Nodes.build_quoted(glob.pattern), case_sensitive: glob.case_sensitive)
78
+ })
79
+ end
80
+ end
81
+
82
+ def turbopuffer_namespace? = turbopuffer_attributes.any?
83
+
84
+ def turbopuffer_schema_hash
85
+ turbopuffer_attributes.each_with_object({}) do |attribute, schema|
86
+ attrs = { type: attribute.type }
87
+
88
+ attrs[:filterable] = true if attribute.filterable
89
+ attrs[:full_text_search] = true if attribute.full_text_search
90
+ attrs[:ann] = true if attribute.ann
91
+ attrs[:glob] = true if attribute.glob
92
+ attrs[:regex] = true if attribute.regex
93
+
94
+ schema[attribute.name] = attrs
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,13 @@
1
+ require "arel/nodes/glob"
2
+ require "arel/nodes/rank_by_node"
3
+ require "turbopuffer/active_record/glob"
4
+ require "turbopuffer/active_record/schema/attribute"
5
+ require "turbopuffer/active_record/schema/model"
6
+
7
+ module Turbopuffer
8
+ module ActiveRecord
9
+ module Schema
10
+ class UnknownNamespace < StandardError; end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Type
4
+ class Array < ::ActiveModel::Type::Value
5
+ include ::ActiveModel::Type::Helpers::Mutable
6
+
7
+ def initialize(element_type)
8
+ @element_type = element_type
9
+ super()
10
+ end
11
+
12
+ def serialize(value)
13
+ case value
14
+ when nil then nil
15
+ when ::Array then value.map { |element| @element_type.serialize(element) }
16
+ else @element_type.serialize(value)
17
+ end
18
+ end
19
+
20
+ def deserialize(value)
21
+ case value
22
+ when nil then nil
23
+ when ::Array then value.map { |element| @element_type.deserialize(element) }
24
+ else @element_type.deserialize(value)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Type
4
+ class DateTime < ::ActiveRecord::Type::DateTime
5
+ def serialize(value)
6
+ casted = cast(value)
7
+
8
+ if casted.respond_to?(:utc)
9
+ casted.utc.iso8601
10
+ elsif casted.is_a?(::Date)
11
+ ::Time.utc(casted.year, casted.month, casted.day).iso8601
12
+ else
13
+ casted
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Type
4
+ class UnsignedInteger < ::ActiveRecord::Type::Integer
5
+ private
6
+
7
+ def max_value = 1 << 64
8
+ def min_value = 0
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,30 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module Type
4
+ class Vector < ::ActiveModel::Type::Value
5
+ include ::ActiveModel::Type::Helpers::Mutable
6
+
7
+ def initialize(dimensions)
8
+ @dimensions = dimensions
9
+ super()
10
+ end
11
+
12
+ def serialize(value)
13
+ return if value.nil?
14
+
15
+ vector = ::Array.wrap(value).map { |component| Float(component) }
16
+
17
+ unless vector.size == @dimensions
18
+ raise ArgumentError, "expected a vector of #{@dimensions} dimensions, got #{vector.size}"
19
+ end
20
+
21
+ vector
22
+ end
23
+
24
+ def deserialize(value)
25
+ value
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ require "turbopuffer/active_record/type/date_time"
2
+ require "turbopuffer/active_record/type/unsigned_integer"
3
+ require "turbopuffer/active_record/type/array"
4
+ require "turbopuffer/active_record/type/vector"
5
+
6
+ module Turbopuffer
7
+ module ActiveRecord
8
+ module Type
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ require "turbopuffer/active_record/version"
2
+
3
+ module Turbopuffer
4
+ module ActiveRecord
5
+ class Error < StandardError; end
6
+
7
+ # Teaches Active Record that `adapter: turbopuffer` in database.yml maps to
8
+ # our adapter. The railtie calls this during boot; test harnesses and
9
+ # non-Rails consumers call it themselves.
10
+ def self.register_adapter!
11
+ ::ActiveRecord::ConnectionAdapters.register(
12
+ "turbopuffer",
13
+ "ActiveRecord::ConnectionAdapters::TurbopufferAdapter",
14
+ "active_record/connection_adapters/turbopuffer_adapter"
15
+ )
16
+ end
17
+ end
18
+ end
19
+
20
+ require "turbopuffer/active_record/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-turbopuffer-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Richard Monette
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-09-18 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '8.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: turbopuffer
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '3'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '2.4'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '3'
60
+ description: Interact with turbopuffer through a native feeling Active Record database
61
+ adapter
62
+ email: richard.monette@gmail.com
63
+ executables: []
64
+ extensions: []
65
+ extra_rdoc_files:
66
+ - README.md
67
+ files:
68
+ - LICENSE.txt
69
+ - README.md
70
+ - lib/active_record/connection_adapters/turbopuffer_adapter.rb
71
+ - lib/activerecord-turbopuffer-adapter.rb
72
+ - lib/arel/nodes/glob.rb
73
+ - lib/arel/nodes/rank_by_node.rb
74
+ - lib/arel/visitors/turbopuffer.rb
75
+ - lib/turbopuffer/active_record.rb
76
+ - lib/turbopuffer/active_record/glob.rb
77
+ - lib/turbopuffer/active_record/railtie.rb
78
+ - lib/turbopuffer/active_record/schema.rb
79
+ - lib/turbopuffer/active_record/schema/attribute.rb
80
+ - lib/turbopuffer/active_record/schema/model.rb
81
+ - lib/turbopuffer/active_record/type.rb
82
+ - lib/turbopuffer/active_record/type/array.rb
83
+ - lib/turbopuffer/active_record/type/date_time.rb
84
+ - lib/turbopuffer/active_record/type/unsigned_integer.rb
85
+ - lib/turbopuffer/active_record/type/vector.rb
86
+ - lib/turbopuffer/active_record/version.rb
87
+ homepage: https://github.com/richardmonette/activerecord-turbopuffer-adapter
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ allowed_push_host: https://rubygems.org
92
+ source_code_uri: https://github.com/richardmonette/activerecord-turbopuffer-adapter
93
+ changelog_uri: https://github.com/richardmonette/activerecord-turbopuffer-adapter/blob/main/CHANGELOG.md
94
+ rubygems_mfa_required: 'true'
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 3.3.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.6.2
110
+ specification_version: 4
111
+ summary: Active Record adapter for turbopuffer
112
+ test_files: []