activerecord-turbopuffer-adapter 0.1.7 → 0.1.8

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: dbe88ca95c2999749426d72f89dd7d0327a53d5a08bf6a0b2446340d6b17e173
4
- data.tar.gz: c6c1611fe66ea9ca7dff51fde19ce3843249d8b91e955aa21ba500d9fe8efe1a
3
+ metadata.gz: f7e4579b0235c712aef39dedae94abdb3e8053ef5b04fff2e46ee38c0fd5710a
4
+ data.tar.gz: dadc48af421f105ed95d7964b7929ced1dd613217fdda1b88b00a58c291867cf
5
5
  SHA512:
6
- metadata.gz: 4cd6c79fd3b22a79ca2d18a8ed68765360b7e63c9a3f2d4e3bb82d644553bbf7fcd93a05489f58c3285c5967ec3f2e2f522bafa7e4c1c9debf5190b4f626c85c
7
- data.tar.gz: c8f7cdae01828498d93c66d345d34bdbf5cda39ff46ff13ec7126bfe97b42bba3d9c4b2337fbce08640755f87d49d42b9283cec227f9605d6b1af02bb9b1f998
6
+ metadata.gz: 6037d2898aa914ed3990f238ac00b86c4b710d630fd3aa7c9dd3af9200f768736b9207cf8cedf079c01d60fd8fce614cdafdaa5873241b1c7a47fa1f6bb4b9df
7
+ data.tar.gz: 8b6c7caf5e1b82fbed33da7e5e06e1232cf9603aab035b6a9ef8540223047020429927721928911c80512c778fbdef5a52b1628ca0110ac4414d0419f18a9aed
data/README.md CHANGED
@@ -132,6 +132,20 @@ Document
132
132
 
133
133
  > Eventual reads are cheaper but may not include writes from the last few seconds. Set a default with `turbopuffer_consistency "eventual"` in a model or `consistency: eventual` in `database.yml`. Strong is the default.
134
134
 
135
+ ### Namespaces per tenant
136
+
137
+ turbopuffer recommends one namespace per tenant: a query can only ever see that tenant's documents, and search covers just the documents that matter rather than everyone's. `namespace` scopes a model to any namespace, and records remember where they were loaded from, so `update!`, `reload` and `destroy` go back to the same place.
138
+
139
+ ```ruby
140
+ Document.namespace("documents-#{tenant.id}").create!(title: "Hello")
141
+ Document.namespace("documents-#{tenant.id}").where(published: true)
142
+
143
+ doc = Document.namespace("documents-#{tenant.id}").find(id)
144
+ doc.update!(published: true)
145
+ ```
146
+
147
+ The name is used as given (under `namespace_prefix` if one is set). Without `namespace`, a model's table name is its namespace.
148
+
135
149
  ### Errors
136
150
 
137
151
  Turbopuffer errors are raised as the ActiveRecord exceptions a Rails app already handles.
@@ -299,8 +299,7 @@ module ActiveRecord
299
299
  def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
300
300
  tpuf_query = sql.is_a?(Array) ? sql.first : sql
301
301
 
302
- namespace_name = [ @config[:namespace_prefix], tpuf_query.namespace ].compact_blank.join("-")
303
- namespace = raw_connection.namespace(namespace_name)
302
+ namespace = raw_connection.namespace(namespace_name_for(tpuf_query))
304
303
 
305
304
  result = case tpuf_query.op
306
305
  when :insert then turbopuffer_insert(namespace, tpuf_query)
@@ -318,6 +317,12 @@ module ActiveRecord
318
317
  result
319
318
  end
320
319
 
320
+ def namespace_name_for(query)
321
+ override = query.namespace_override || ::Turbopuffer::ActiveRecord.namespace_override
322
+
323
+ [ @config[:namespace_prefix], override || query.namespace ].compact_blank.join("-")
324
+ end
325
+
321
326
  def arel_visitor
322
327
  Arel::Visitors::Turbopuffer.new
323
328
  end
@@ -11,7 +11,8 @@ module Arel::Visitors
11
11
  include_attributes: @include_attributes,
12
12
  aggregate_by: @aggregate_by,
13
13
  upsert_rows: @upsert_rows,
14
- consistency: @consistency
14
+ consistency: @consistency,
15
+ namespace_override: @namespace_override
15
16
  }
16
17
  end
17
18
  def to_s = JSON.generate(to_h)
@@ -21,10 +22,10 @@ module Arel::Visitors
21
22
  alias == eql?
22
23
 
23
24
  attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by,
24
- :consistency
25
+ :consistency, :namespace_override
25
26
 
26
27
  def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil, include_attributes: nil,
27
- upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil)
28
+ upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil, namespace_override: nil)
28
29
  @op = op
29
30
  @namespace = namespace
30
31
  @filters = filters
@@ -35,6 +36,7 @@ module Arel::Visitors
35
36
  @group_by = group_by
36
37
  @upsert_rows = upsert_rows
37
38
  @consistency = consistency
39
+ @namespace_override = namespace_override
38
40
  end
39
41
  end
40
42
 
@@ -124,10 +126,19 @@ module Arel::Visitors
124
126
  include_attributes: attributes.flat_map { |p| visit(p) },
125
127
  group_by: core.groups.map { |g| visit(g) },
126
128
  aggregate_by: aggregate && visit(aggregate),
127
- consistency: consistency_hint(core.optimizer_hints)
129
+ consistency: consistency_hint(core.optimizer_hints),
130
+ namespace_override: namespace_annotation(core.comment)
128
131
  )
129
132
  end
130
133
 
134
+ def namespace_annotation(comment)
135
+ return unless comment
136
+
137
+ prefix = ::Turbopuffer::ActiveRecord::NAMESPACE_ANNOTATION
138
+ annotation = comment.values.reverse.find { |value| value.start_with?(prefix) }
139
+ annotation&.delete_prefix(prefix)
140
+ end
141
+
131
142
  def consistency_hint(hints)
132
143
  return unless hints
133
144
 
@@ -152,18 +163,20 @@ module Arel::Visitors
152
163
  rows = o.values ? visit(o.values) : [ [] ]
153
164
 
154
165
  TurbopufferQuery.new(
155
- op: :update,
156
- namespace: visit(o.relation),
157
- filters: conjoin(o.wheres.map { |w| visit(w) }),
158
- upsert_rows: rows
166
+ op: :update,
167
+ namespace: visit(o.relation),
168
+ filters: conjoin(o.wheres.map { |w| visit(w) }),
169
+ upsert_rows: rows,
170
+ namespace_override: namespace_annotation(o.comment)
159
171
  )
160
172
  end
161
173
 
162
174
  def visit_Arel_Nodes_DeleteStatement(o)
163
175
  TurbopufferQuery.new(
164
- op: :delete,
165
- namespace: visit(o.relation),
166
- filters: conjoin(o.wheres.map { |w| visit(w) })
176
+ op: :delete,
177
+ namespace: visit(o.relation),
178
+ filters: conjoin(o.wheres.map { |w| visit(w) }),
179
+ namespace_override: namespace_annotation(o.comment)
167
180
  )
168
181
  end
169
182
 
@@ -0,0 +1,15 @@
1
+ module Turbopuffer
2
+ module ActiveRecord
3
+ module NamespaceScope
4
+ def turbopuffer_namespace
5
+ annotation = annotate_values.reverse.find { |value| value.start_with?(NAMESPACE_ANNOTATION) }
6
+ annotation&.delete_prefix(NAMESPACE_ANNOTATION)
7
+ end
8
+
9
+ def exec_queries(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
10
+ def insert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
11
+ def insert_all!(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
12
+ def upsert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
13
+ end
14
+ end
15
+ end
@@ -13,6 +13,27 @@ module Turbopuffer
13
13
  class_attribute :_turbopuffer_consistency, instance_accessor: false, default: nil
14
14
  end
15
15
 
16
+ def turbopuffer_namespace = @turbopuffer_namespace
17
+
18
+ def update_columns(...) = with_turbopuffer_namespace { super }
19
+
20
+ private
21
+
22
+ def init_internals
23
+ @turbopuffer_namespace = ::Turbopuffer::ActiveRecord.namespace_override ||
24
+ self.class.current_scope.try(:turbopuffer_namespace)
25
+ super
26
+ end
27
+
28
+ def _create_record(...) = with_turbopuffer_namespace { super }
29
+ def _update_row(...) = with_turbopuffer_namespace { super }
30
+ def _delete_row(...) = with_turbopuffer_namespace { super }
31
+ def _find_record(...) = with_turbopuffer_namespace { super }
32
+
33
+ def with_turbopuffer_namespace(&block)
34
+ ::Turbopuffer::ActiveRecord.with_namespace(@turbopuffer_namespace, &block)
35
+ end
36
+
16
37
  class << self
17
38
  def namespaces
18
39
  ::ActiveRecord::Base.descendants.select do |model|
@@ -82,6 +103,10 @@ module Turbopuffer
82
103
  select(*turbopuffer_attributes.map(&:name))
83
104
  end
84
105
 
106
+ def namespace(name)
107
+ annotate("#{NAMESPACE_ANNOTATION}#{name}").extending(::Turbopuffer::ActiveRecord::NamespaceScope)
108
+ end
109
+
85
110
  def glob(pattern, case_sensitive: true)
86
111
  ::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
87
112
  end
@@ -1,6 +1,7 @@
1
1
  require "arel/nodes/glob"
2
2
  require "arel/nodes/rank_by_node"
3
3
  require "turbopuffer/active_record/glob"
4
+ require "turbopuffer/active_record/namespace_scope"
4
5
  require "turbopuffer/active_record/schema/attribute"
5
6
  require "turbopuffer/active_record/schema/model"
6
7
 
@@ -1,5 +1,5 @@
1
1
  module Turbopuffer
2
2
  module ActiveRecord
3
- VERSION = "0.1.7"
3
+ VERSION = "0.1.8"
4
4
  end
5
5
  end
@@ -4,6 +4,20 @@ module Turbopuffer
4
4
  module ActiveRecord
5
5
  class Error < StandardError; end
6
6
 
7
+ NAMESPACE_ANNOTATION = "turbopuffer_namespace="
8
+
9
+ def self.namespace_override
10
+ ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace]
11
+ end
12
+
13
+ def self.with_namespace(name)
14
+ previous = namespace_override
15
+ ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = name
16
+ yield
17
+ ensure
18
+ ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = previous
19
+ end
20
+
7
21
  # Teaches Active Record that `adapter: turbopuffer` in database.yml maps to
8
22
  # our adapter. The railtie calls this during boot; test harnesses and
9
23
  # non-Rails consumers call it themselves.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-turbopuffer-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Monette
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-06 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -27,16 +27,16 @@ dependencies:
27
27
  name: base64
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: '0.2'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: '0.2'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: railties
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -89,6 +89,7 @@ files:
89
89
  - lib/arel/visitors/turbopuffer.rb
90
90
  - lib/turbopuffer/active_record.rb
91
91
  - lib/turbopuffer/active_record/glob.rb
92
+ - lib/turbopuffer/active_record/namespace_scope.rb
92
93
  - lib/turbopuffer/active_record/railtie.rb
93
94
  - lib/turbopuffer/active_record/schema.rb
94
95
  - lib/turbopuffer/active_record/schema/attribute.rb