s3arch 0.0.9 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc88f938520606c4d992c896525041fe84635c7851ab3aba93fd6e06df28b370
4
- data.tar.gz: abbd30f7e02a7e7ae5de4af5c85fb4f83b386f2ca11626c0a0f0a3831e99be7e
3
+ metadata.gz: 89f4fce79a53db6b2890bf9034c5702e556ce27a1f399e8d4c605b8953fcd033
4
+ data.tar.gz: 13b9092d968a9aa1a44dcacbe9bd9ede1304e8a9e67623219245e0f601a9d467
5
5
  SHA512:
6
- metadata.gz: c88f194638ec1fa29f3fc1705efedaf9f5783f373e324fc7053188676572dd5e66f26bd81adb27248906f04779a614e633958a3a64eb3c365c36de17b942937b
7
- data.tar.gz: bc74c8e5b04a667d7a1eb0b4339d9eb717b62c77e92cc0701dc139e2b7f5bf46aa2ada29a03df9f9b9578e7e8b84a7eee86ac6b77fa9b149d29ab442e2890bc8
6
+ metadata.gz: 1c2093cac0b0e12bc0a9fe679d218fa13aa6b1dfeb35b1bd69bef03dbf1699eaba32084931330e3322993981d541787966e270803e566716ea4b61de9edaddfe
7
+ data.tar.gz: c6d81a49d383c03c03638a0d6548acc7df697b3fa3f1dc6bc26f8347bf03bb6f29a63707af68baa8920f45dceea74f3fe886533a4914c3af281149271f0b00e1
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.0] - 2026-06-18
4
+
5
+ ### Added
6
+
7
+ - `S3arch::Store` — thin adapter layer encapsulating all DynamoDB/S3 operations (fetch records, upload/download indexes, version tracking). Indexer and Searcher accept an injected `store:` parameter for testability.
8
+ - `S3arch::Models::SearchVersion` — lightweight model for the version tracking table with `.all`, `.find`, `.increment!`, and `#to_h`
9
+ - Comprehensive unit specs (84 examples): Tokenizer, Indexer, Searcher, StreamParser, Handler, Store, SearchVersion
10
+
11
+ ### Changed
12
+
13
+ - `Indexer` and `Searcher` no longer call AWS SDKs directly — delegated to `Store`
14
+ - `S3archController` simplified to use `SearchVersion.all` instead of inline DynamoDB scanning
15
+
3
16
  ## [0.0.5] - 2025-06-12
4
17
 
5
18
  ### Added
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aws-sdk-dynamodb'
3
+ require_relative '../models/search_version'
4
4
 
5
5
  class S3archController < BeltController::Base
6
6
  def index
7
- owners = fetch_owners
8
- success_response(owners: owners)
7
+ versions = S3arch::Models::SearchVersion.all
8
+ success_response(owners: versions.map(&:to_h))
9
9
  rescue StandardError => e
10
10
  error_response("Failed to load owners: #{e.message}", 500)
11
11
  end
@@ -20,41 +20,9 @@ class S3archController < BeltController::Base
20
20
  else
21
21
  S3arch::Indexer.new.rebuild(owner_id)
22
22
  end
23
+
23
24
  success_response(status: 'ok', owner_id: owner_id)
24
25
  rescue StandardError => e
25
26
  error_response("Failed to rebuild index: #{e.message}", 500)
26
27
  end
27
-
28
- private
29
-
30
- def fetch_owners
31
- dynamodb = Aws::DynamoDB::Client.new
32
- config = S3arch.configuration
33
- items = []
34
- scan_params = { table_name: config.version_table }
35
-
36
- loop do
37
- result = dynamodb.scan(scan_params)
38
- items.concat(result.items)
39
- break unless result.last_evaluated_key
40
-
41
- scan_params[:exclusive_start_key] = result.last_evaluated_key
42
- end
43
-
44
- owners = items.map do |item|
45
- {
46
- owner_id: item[config.owner_key] || item.values.first,
47
- version: format_version(item['version']),
48
- record_count: item['record_count'],
49
- updated_at: item['updated_at']
50
- }
51
- end
52
- owners.sort_by { |o| o[:updated_at].to_s }.reverse
53
- end
54
-
55
- def format_version(version)
56
- return version unless version
57
-
58
- version.to_i.to_s
59
- end
60
28
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3arch
4
+ module Models
5
+ # Lightweight model for the version tracking table.
6
+ # Each record represents one owner's index state.
7
+ class SearchVersion
8
+ attr_reader :owner_id, :version, :record_count, :updated_at
9
+
10
+ def initialize(owner_id:, version: 0, record_count: 0, updated_at: nil)
11
+ @owner_id = owner_id
12
+ @version = version.to_i
13
+ @record_count = record_count.to_i
14
+ @updated_at = updated_at
15
+ end
16
+
17
+ def to_h
18
+ { owner_id: owner_id, version: version.to_s, record_count: record_count, updated_at: updated_at }
19
+ end
20
+
21
+ class << self
22
+ def all(config: S3arch.configuration)
23
+ items = []
24
+ params = { table_name: config.version_table }
25
+
26
+ loop do
27
+ result = Aws::DynamoDB::Client.new.scan(params)
28
+ items.concat(result.items)
29
+ break unless result.last_evaluated_key
30
+
31
+ params[:exclusive_start_key] = result.last_evaluated_key
32
+ end
33
+
34
+ items.map { |item| from_dynamo(item, config) }
35
+ .sort_by { |v| v.updated_at.to_s }
36
+ .reverse
37
+ end
38
+
39
+ def find(owner_id, config: S3arch.configuration)
40
+ result = Aws::DynamoDB::Client.new.get_item(table_name: config.version_table,
41
+ key: { config.owner_key => owner_id },
42
+ projection_expression: 'version')
43
+ return nil unless result.item
44
+
45
+ result.item['version']&.to_i
46
+ end
47
+
48
+ def increment!(owner_id, record_count:, config: S3arch.configuration)
49
+ Aws::DynamoDB::Client.new.update_item(
50
+ table_name: config.version_table,
51
+ key: { config.owner_key => owner_id },
52
+ update_expression: 'SET version = if_not_exists(version, :zero) + :one, ' \
53
+ 'updated_at = :now, record_count = :count',
54
+ expression_attribute_values: { ':zero' => 0, ':one' => 1, ':now' => Time.now.iso8601,
55
+ ':count' => record_count }
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def from_dynamo(item, config)
62
+ new(
63
+ owner_id: item[config.owner_key] || item.values.first,
64
+ version: item['version'],
65
+ record_count: item['record_count'],
66
+ updated_at: item['updated_at']
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aws-sdk-dynamodb'
4
- require 'aws-sdk-s3'
5
3
  require 'sqlite3'
6
4
  require 'json'
7
5
  require_relative 'indexer/stream_parser'
@@ -13,22 +11,20 @@ module S3arch
13
11
  class Indexer
14
12
  include StreamParser
15
13
 
16
- def initialize(config: S3arch.configuration)
14
+ def initialize(config: S3arch.configuration, store: nil)
17
15
  config.validate!
18
16
  @config = config
19
- @dynamodb = Aws::DynamoDB::Client.new
20
- @s3 = Aws::S3::Client.new
17
+ @store = store || Store.new(config: config)
21
18
  end
22
19
 
23
20
  # Full rebuild — pulls all tokens from DynamoDB for an owner.
24
- # Used for initial backfill or when incremental isn't possible.
25
21
  def rebuild(owner_id)
26
- records = fetch_records(owner_id)
22
+ records = @store.fetch_records(owner_id)
27
23
  db_path = "/tmp/s3arch_#{owner_id}.sqlite3"
28
24
 
29
25
  build_database(db_path, records)
30
- upload(owner_id, db_path)
31
- increment_version(owner_id, records.size)
26
+ @store.upload_index(owner_id, db_path)
27
+ @store.increment_version(owner_id, records.size)
32
28
 
33
29
  log(:info, 'Index rebuilt', owner_id: owner_id, record_count: records.size)
34
30
  ensure
@@ -36,12 +32,10 @@ module S3arch
36
32
  end
37
33
 
38
34
  # Incremental update — applies INSERT/DELETE/UPDATE to an existing index.
39
- # Downloads current DB from S3, applies changes, re-uploads.
40
35
  def apply_changes(owner_id, changes)
41
36
  db_path = "/tmp/s3arch_#{owner_id}.sqlite3"
42
- download_existing(owner_id, db_path)
43
37
 
44
- unless File.exist?(db_path)
38
+ unless @store.download_index(owner_id, db_path)
45
39
  log(:info, 'No existing index, doing full rebuild', owner_id: owner_id)
46
40
  return rebuild(owner_id)
47
41
  end
@@ -56,8 +50,8 @@ module S3arch
56
50
  record_count = db.get_first_value('SELECT COUNT(*) FROM records_meta')
57
51
  db.close
58
52
 
59
- upload(owner_id, db_path)
60
- increment_version(owner_id, record_count)
53
+ @store.upload_index(owner_id, db_path)
54
+ @store.increment_version(owner_id, record_count)
61
55
 
62
56
  log(:info, 'Index updated incrementally', owner_id: owner_id, changes: changes.size, record_count: record_count)
63
57
  ensure
@@ -65,7 +59,6 @@ module S3arch
65
59
  end
66
60
 
67
61
  # Process SQS event containing DynamoDB stream records.
68
- # Groups by owner and applies incremental changes.
69
62
  def process_event(event)
70
63
  sqs_records = event['Records'] || []
71
64
  grouped = group_changes(sqs_records)
@@ -83,8 +76,6 @@ module S3arch
83
76
  { statusCode: 200, body: JSON.generate(rebuilt: grouped.size) }
84
77
  end
85
78
 
86
- RESERVED_WORDS = Set.new(%w[status name comment count size type]).freeze
87
-
88
79
  private
89
80
 
90
81
  def apply_change(db, change)
@@ -101,7 +92,6 @@ module S3arch
101
92
  delete_row(db, rowid, change[:old_tokens])
102
93
  insert_row(db, rowid, change[:record_id], change[:new_tokens], change[:meta])
103
94
  else
104
- # Record wasn't in index yet, just insert
105
95
  new_rowid = next_rowid(db)
106
96
  insert_row(db, new_rowid, change[:record_id], change[:new_tokens], change[:meta])
107
97
  end
@@ -126,7 +116,6 @@ module S3arch
126
116
  fts_cols = @config.searchable_fields
127
117
  fts_values = fts_cols.map { |f| tokens[f] || '' }
128
118
  placeholders = (['?'] * (fts_values.size + 1)).join(', ')
129
- # FTS5 contentless delete: INSERT with special 'delete' command
130
119
  fts_delete_sql = "INSERT INTO records_fts(records_fts, rowid, #{fts_cols.join(', ')}) " \
131
120
  "VALUES ('delete', #{placeholders})"
132
121
  db.execute(fts_delete_sql, [rowid] + fts_values)
@@ -142,70 +131,6 @@ module S3arch
142
131
  (max || 0) + 1
143
132
  end
144
133
 
145
- def download_existing(owner_id, db_path)
146
- @s3.get_object(bucket: @config.index_bucket, key: "#{owner_id}/index.sqlite3", response_target: db_path)
147
- rescue Aws::S3::Errors::NoSuchKey
148
- # No existing index — caller will fall back to rebuild
149
- end
150
-
151
- # Full rebuild: fetches token field from DynamoDB (never reads content)
152
- def fetch_records(owner_id)
153
- records = []
154
- params = build_query_params(owner_id)
155
-
156
- loop do
157
- result = @dynamodb.query(params)
158
- result.items.each do |item|
159
- next unless @config.record_filter.call(item)
160
-
161
- tokens = extract_tokens_from_item(item)
162
- next unless tokens.is_a?(Hash) && tokens.any?
163
-
164
- records << { 'id' => item['id'], 'tokens' => tokens, 'meta' => extract_meta_from_item(item) }
165
- end
166
- break unless result.last_evaluated_key
167
-
168
- params[:exclusive_start_key] = result.last_evaluated_key
169
- end
170
-
171
- records
172
- end
173
-
174
- def extract_tokens_from_item(item)
175
- # Try the dedicated token field first
176
- tokens = item[@config.token_field]
177
- return tokens if tokens.is_a?(Hash) && tokens.any?
178
-
179
- # Fall back: synthesize tokens from searchable_fields directly
180
- @config.searchable_fields.each_with_object({}) do |field, map|
181
- val = item[field]
182
- map[field] = val.to_s if val
183
- end
184
- end
185
-
186
- def build_query_params(owner_id)
187
- fields = (['id', @config.token_field, @config.owner_key] +
188
- @config.searchable_fields + @config.metadata_fields + @config.filter_fields).compact.uniq
189
- expression_names = {}
190
- projected = fields.map { |f| reserved_word?(f) ? "##{f}".tap { |p| expression_names[p] = f } : f }
191
-
192
- owner_placeholder = reserved_word?(@config.owner_key) ? "##{@config.owner_key}" : @config.owner_key
193
- expression_names["##{@config.owner_key}"] = @config.owner_key if reserved_word?(@config.owner_key)
194
-
195
- params = { table_name: @config.source_table, index_name: @config.source_index,
196
- key_condition_expression: "#{owner_placeholder} = :owner",
197
- expression_attribute_values: { ':owner' => owner_id },
198
- projection_expression: projected.join(', ') }
199
- params[:expression_attribute_names] = expression_names if expression_names.any?
200
- params
201
- end
202
-
203
- def extract_meta_from_item(item)
204
- @config.metadata_fields.to_h { |field| [field, item[field].to_s] }
205
- end
206
-
207
- def reserved_word?(field) = RESERVED_WORDS.include?(field.downcase)
208
-
209
134
  def build_database(db_path, records)
210
135
  FileUtils.rm_f(db_path)
211
136
  db = SQLite3::Database.new(db_path)
@@ -237,20 +162,6 @@ module S3arch
237
162
  db.close
238
163
  end
239
164
 
240
- def upload(owner_id, db_path)
241
- @s3.put_object(bucket: @config.index_bucket, key: "#{owner_id}/index.sqlite3", body: File.open(db_path, 'rb'))
242
- end
243
-
244
- def increment_version(owner_id, record_count)
245
- @dynamodb.update_item(
246
- table_name: @config.version_table,
247
- key: { @config.owner_key => owner_id },
248
- update_expression: 'SET version = if_not_exists(version, :zero) + :one, ' \
249
- 'updated_at = :now, record_count = :count',
250
- expression_attribute_values: { ':zero' => 0, ':one' => 1, ':now' => Time.now.iso8601, ':count' => record_count }
251
- )
252
- end
253
-
254
165
  def log(level, message, **data)
255
166
  return unless @config.logger
256
167
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aws-sdk-dynamodb'
4
- require 'aws-sdk-s3'
5
3
  require 'sqlite3'
6
4
 
7
5
  module S3arch
@@ -18,11 +16,10 @@ module S3arch
18
16
  end
19
17
  end
20
18
 
21
- def initialize(config: S3arch.configuration)
19
+ def initialize(config: S3arch.configuration, store: nil)
22
20
  config.validate!
23
21
  @config = config
24
- @dynamodb = Aws::DynamoDB::Client.new
25
- @s3 = Aws::S3::Client.new
22
+ @store = store || Store.new(config: config)
26
23
  end
27
24
 
28
25
  def search(query:, owner_ids:, filters: {})
@@ -74,23 +71,18 @@ module S3arch
74
71
  cached = self.class.version_cache[owner_id]
75
72
  return cached[:version] if cached && (Time.now - cached[:checked_at]) < @config.version_ttl
76
73
 
77
- result = @dynamodb.get_item(table_name: @config.version_table,
78
- key: { @config.owner_key => owner_id },
79
- projection_expression: 'version')
80
- version = result.item&.dig('version')
74
+ version = @store.fetch_version(owner_id)
81
75
  self.class.version_cache[owner_id] = { version: version, checked_at: Time.now } if version
82
76
  version
83
77
  end
84
78
 
85
79
  def download_database(owner_id)
86
80
  db_path = "/tmp/s3arch_#{owner_id}.sqlite3"
87
- @s3.get_object(bucket: @config.index_bucket, key: "#{owner_id}/index.sqlite3",
88
- response_target: db_path)
81
+ return nil unless @store.download_index(owner_id, db_path)
82
+
89
83
  db = SQLite3::Database.new(db_path)
90
84
  db.results_as_hash = true
91
85
  db
92
- rescue Aws::S3::Errors::NoSuchKey
93
- nil
94
86
  end
95
87
 
96
88
  def query_fts(db, query, filters: {})
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-dynamodb'
4
+ require 'aws-sdk-s3'
5
+
6
+ module S3arch
7
+ # Thin adapter layer encapsulating all DynamoDB/S3 operations for index lifecycle.
8
+ # Indexer and Searcher delegate here instead of calling AWS SDKs directly.
9
+ class Store
10
+ def initialize(config: S3arch.configuration)
11
+ @config = config
12
+ @dynamodb = Aws::DynamoDB::Client.new
13
+ @s3 = Aws::S3::Client.new
14
+ end
15
+
16
+ # --- Source data (read tokens from host app's DynamoDB table) ---
17
+
18
+ def fetch_records(owner_id)
19
+ records = []
20
+ params = build_query_params(owner_id)
21
+
22
+ loop do
23
+ result = @dynamodb.query(params)
24
+ result.items.each do |item|
25
+ next unless @config.record_filter.call(item)
26
+
27
+ tokens = extract_tokens_from_item(item)
28
+ next unless tokens.is_a?(Hash) && tokens.any?
29
+
30
+ records << { 'id' => item['id'], 'tokens' => tokens, 'meta' => extract_meta(item) }
31
+ end
32
+ break unless result.last_evaluated_key
33
+
34
+ params[:exclusive_start_key] = result.last_evaluated_key
35
+ end
36
+
37
+ records
38
+ end
39
+
40
+ # --- Index files (SQLite databases on S3) ---
41
+
42
+ def upload_index(owner_id, db_path)
43
+ @s3.put_object(bucket: @config.index_bucket, key: index_key(owner_id), body: File.open(db_path, 'rb'))
44
+ end
45
+
46
+ def download_index(owner_id, db_path)
47
+ @s3.get_object(bucket: @config.index_bucket, key: index_key(owner_id), response_target: db_path)
48
+ true
49
+ rescue Aws::S3::Errors::NoSuchKey
50
+ false
51
+ end
52
+
53
+ # --- Version tracking ---
54
+
55
+ def fetch_version(owner_id)
56
+ result = @dynamodb.get_item(table_name: @config.version_table,
57
+ key: { @config.owner_key => owner_id },
58
+ projection_expression: 'version')
59
+ result.item&.dig('version')&.to_i
60
+ end
61
+
62
+ def increment_version(owner_id, record_count)
63
+ @dynamodb.update_item(
64
+ table_name: @config.version_table,
65
+ key: { @config.owner_key => owner_id },
66
+ update_expression: 'SET version = if_not_exists(version, :zero) + :one, ' \
67
+ 'updated_at = :now, record_count = :count',
68
+ expression_attribute_values: { ':zero' => 0, ':one' => 1, ':now' => Time.now.iso8601, ':count' => record_count }
69
+ )
70
+ end
71
+
72
+ RESERVED_WORDS = Set.new(%w[status name comment count size type]).freeze
73
+
74
+ private
75
+
76
+ def index_key(owner_id) = "#{owner_id}/index.sqlite3"
77
+
78
+ def build_query_params(owner_id)
79
+ fields = (['id', @config.token_field, @config.owner_key] +
80
+ @config.searchable_fields + @config.metadata_fields + @config.filter_fields).compact.uniq
81
+ expression_names = {}
82
+ projected = fields.map { |f| reserved_word?(f) ? "##{f}".tap { |p| expression_names[p] = f } : f }
83
+
84
+ owner_placeholder = reserved_word?(@config.owner_key) ? "##{@config.owner_key}" : @config.owner_key
85
+ expression_names["##{@config.owner_key}"] = @config.owner_key if reserved_word?(@config.owner_key)
86
+
87
+ params = { table_name: @config.source_table, index_name: @config.source_index,
88
+ key_condition_expression: "#{owner_placeholder} = :owner",
89
+ expression_attribute_values: { ':owner' => owner_id },
90
+ projection_expression: projected.join(', ') }
91
+ params[:expression_attribute_names] = expression_names if expression_names.any?
92
+ params
93
+ end
94
+
95
+ def extract_tokens_from_item(item)
96
+ tokens = item[@config.token_field]
97
+ return tokens if tokens.is_a?(Hash) && tokens.any?
98
+
99
+ @config.searchable_fields.each_with_object({}) do |field, map|
100
+ val = item[field]
101
+ map[field] = val.to_s if val
102
+ end
103
+ end
104
+
105
+ def extract_meta(item)
106
+ @config.metadata_fields.to_h { |field| [field, item[field].to_s] }
107
+ end
108
+
109
+ def reserved_word?(field) = RESERVED_WORDS.include?(field.downcase)
110
+ end
111
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module S3arch
4
- VERSION = '0.0.9'
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/s3arch.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 's3arch/version'
4
4
  require_relative 's3arch/configuration'
5
5
  require_relative 's3arch/tokenizer'
6
+ require_relative 's3arch/store'
6
7
  require_relative 's3arch/indexer'
7
8
  require_relative 's3arch/searcher'
8
9
  require_relative 's3arch/handler'
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: s3arch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Dalton
@@ -64,6 +64,20 @@ dependencies:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
66
  version: '1.0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: belt
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '0.0'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.0'
67
81
  - !ruby/object:Gem::Dependency
68
82
  name: rack
69
83
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +121,7 @@ files:
107
121
  - infrastructure/routes.tf.rb
108
122
  - infrastructure/schema.tf.rb
109
123
  - lambda/controllers/s3arch_controller.rb
124
+ - lambda/models/search_version.rb
110
125
  - lib/s3arch.rb
111
126
  - lib/s3arch/configuration.rb
112
127
  - lib/s3arch/handler.rb
@@ -114,6 +129,7 @@ files:
114
129
  - lib/s3arch/indexer/stream_parser.rb
115
130
  - lib/s3arch/routes.rb
116
131
  - lib/s3arch/searcher.rb
132
+ - lib/s3arch/store.rb
117
133
  - lib/s3arch/tokenizer.rb
118
134
  - lib/s3arch/version.rb
119
135
  - lib/s3arch/web.rb
metadata.gz.sig CHANGED
Binary file