s3arch 0.0.1 → 0.0.4

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: 1d5bd8c8d47da22b8feb308ff8fbc96205d554ae915ef8e4e2831d5ba534f786
4
- data.tar.gz: 4741157e9c522074a08c3478dc4cfddd16445590cfdb107100121083ff34a29a
3
+ metadata.gz: 5b5d2b31636cdd8dd3d02bfd8140a82263f462207bf80525ef55167e1267a057
4
+ data.tar.gz: 8cfa541ae296debeb4ca01737673733805b81268a415280241a57eaba1f22821
5
5
  SHA512:
6
- metadata.gz: 54b5a57df4c767d23eac10d7984ea5ec796f60cfe9047b24619cb3d67600af2b67e71e2f0639704a55939a057f7351f7778a0587b9db6b80fa768eb2398c9e18
7
- data.tar.gz: c4a2ff82ac4985b24376ccacab235e9a56132e1ee10b77202eff9defc62d0b1765ab9da31d89c695b42c3560984d1098df476a6b73b6a4561ad6c1cbefaa4749
6
+ metadata.gz: fd0c3fabee28274d50d46951fde563bc9196263d59174ee87047bdb8a06d7ae1ee7964ae82d431c6c22ee875e40512918a01c6110292d0a0d06c89e54201e25e
7
+ data.tar.gz: '008755884d519604931a7ccfebff4492c0308900230d5e9cd535aa452afb9e7a48d9274c04b15867b2bfd3bbe031aeb2ae08d1c2b6ffdf8759a7a90518ce7b60'
checksums.yaml.gz.sig ADDED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.4] - 2025-06-09
4
+
5
+ ### Fixed
6
+
7
+ - `Indexer#fetch_records` now uses `expression_attribute_names` for DynamoDB reserved words (e.g., `status`), fixing `ValidationException` on `rebuild()`
8
+
9
+ ### Added
10
+
11
+ - `Configuration#filter_fields` — declare which fields the `record_filter` proc needs projected from DynamoDB
12
+
13
+ ### Removed
14
+
15
+ - Hardcoded `filter_fields` method (`%w[status bin_id]`) — replaced by configurable `filter_fields`
16
+
17
+ ## [0.0.2] - 2025-06-08
18
+
19
+ ### Added
20
+
21
+ - `S3arch::Tokenizer` — pure tokenization class for pre-computing search tokens
22
+ - Token-based indexing — indexer reads pre-computed tokens, never raw content
23
+ - Incremental updates via `Indexer#apply_changes` (DynamoDB Stream INSERT/MODIFY/REMOVE)
24
+ - `Indexer#process_event` — processes SQS events containing DynamoDB stream records
25
+ - `Configuration#token_field` — configurable DynamoDB attribute for stored tokens
26
+ - `Configuration#searchable_fields` — configurable list of fields to tokenize
27
+
28
+ ### Changed
29
+
30
+ - `S3arch::Indexer` rewritten to support both full rebuild and incremental updates
31
+ - FTS5 contentless DELETE support using stored token values from stream OLD_IMAGE
32
+
3
33
  ## [0.0.1] - 2025-06-08
4
34
 
5
35
  ### Added
@@ -0,0 +1,26 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEdDCCAtygAwIBAgIBATANBgkqhkiG9w0BAQsFADBAMQ4wDAYDVQQDDAVhZ2Vu
3
+ dDEZMBcGCgmSJomT8ixkARkWCXN0b3d6aWxsYTETMBEGCgmSJomT8ixkARkWA2Nv
4
+ bTAeFw0yNjA2MDgxOTExNTlaFw0yNzA2MDgxOTExNTlaMEAxDjAMBgNVBAMMBWFn
5
+ ZW50MRkwFwYKCZImiZPyLGQBGRYJc3Rvd3ppbGxhMRMwEQYKCZImiZPyLGQBGRYD
6
+ Y29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAupBquKI/4WvXOgND
7
+ pXyqH2GllZs1wG4TWWdn/DoMg45UoCwD+AWEuGrIdInBCpPN8vEJNJWPoM/RrU+b
8
+ xRBZT4uUk00bnZRW2SYh5GJSqBoBR+rWc2DGkXyGfdRU2sQvkB0+is6ChgQ61WMM
9
+ 33LE9+loBlVsZ6EVtrc18Uh2OW0mJpe0hN2nmBrxZqqOZigxC4DKRMFHvpRkxSb6
10
+ mD4kit1AcwX9NEWJsXxrPaetL/SB/VbXaEZX93XAvp6USaXvCWt4slkDS2mIvqtn
11
+ 9DtGC43LFC7SDGbnsG9PVenQgVCi8UWFPUAab0PqZSlmi3Qlbhw8qTGPp5Cbv4vz
12
+ qjC2UGPOQigA/7lbbGRhCohMrjOVHMAQwkcgiIqtolUoYlnvPMIy+m3pdvgDv/PH
13
+ bsZGvXQ7i0458xsmp1vaKthZocVAR+GboHbuIiYPUnO45ccXUQ00x6365tTe7mZi
14
+ NvmUYdAGbQmVvFqyxF7IYA6sF74L2Lstu0knSfss557bAe1HAgMBAAGjeTB3MAkG
15
+ A1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSnxTL/lNBCeLqpeVIX6AUY
16
+ kel4zjAeBgNVHREEFzAVgRNhZ2VudEBzdG93emlsbGEuY29tMB4GA1UdEgQXMBWB
17
+ E2FnZW50QHN0b3d6aWxsYS5jb20wDQYJKoZIhvcNAQELBQADggGBACm9Fjit/UCv
18
+ FxlKqeiCTIG94cIx+QrWAOJSx9knKydwUec1u04D/DbfZjTn3C2Bj227QgxeUn+6
19
+ if3e2v7zAk1896hLmGYzML0+nxQPb0vmtdLR7HETUlSKTVabcv1fbwLyjsuGrBvk
20
+ y51vOEzUEZ508a9yepLYqrQu1kOju4d57c9oA5l3H0mMKWz7av9tFj0B+STvuaWk
21
+ HRYDWc5HgOEVTyV+w0uFt2Kw4OCb8C42uSvC5RfYYtw78MSP+5Ru+LXJ7XOtmuN0
22
+ E6GVmofQ17ig9O3rgfFbMendSInrRmvPIGswvM1yivq9NOllFbdck2OJKPx6FCJF
23
+ 7SJIkXQfc9P4B5iASIV1d1FsE0YX+g3jHXPJK/4mGL5bAyBKzpMfQB/mg6vQBzkh
24
+ aOKPwcreFj7TznBl89R5tNS9wZQfPVR98zgPyocddWhK18eQNMSBUnv4eeJ8PPbk
25
+ DovL+G8ajHDZ9fjH/+GVYHEMuiVdLarXrKJpHC1VfGTTUAp4NSEpUQ==
26
+ -----END CERTIFICATE-----
@@ -2,16 +2,54 @@
2
2
 
3
3
  module S3arch
4
4
  class Configuration
5
- attr_accessor :source_table, :source_index, :owner_key, :index_bucket, :version_table,
6
- :searchable_fields, :metadata_fields, :record_filter, :owner_extractor,
7
- :logger, :version_ttl, :max_results, :max_cached_dbs, :ephemeral_storage_mb
5
+ # DynamoDB table that contains the source records to index
6
+ attr_accessor :source_table
7
+
8
+ # DynamoDB index to query records by owner (e.g., 'UserIndex')
9
+ attr_accessor :source_index
10
+
11
+ # Partition key field on the source table for owner lookup
12
+ attr_accessor :owner_key
13
+
14
+ # S3 bucket for storing SQLite index files
15
+ attr_accessor :index_bucket
16
+
17
+ # DynamoDB table for version tracking
18
+ attr_accessor :version_table
19
+
20
+ # FTS5 searchable fields — array of field names from the source record
21
+ attr_accessor :searchable_fields
22
+
23
+ # DynamoDB attribute name where pre-computed tokens are stored (Map type)
24
+ # e.g., { "searchTokens": { "name": "blue jacket", "description": "warm winter coat" } }
25
+ attr_accessor :token_field
26
+
27
+ # Metadata fields stored alongside FTS5 for filtering (not searched)
28
+ attr_accessor :metadata_fields
29
+
30
+ # Filter proc — receives a record hash, returns true to include in index
31
+ attr_accessor :record_filter
32
+
33
+ # Fields needed by the record_filter for projection (e.g., %w[status])
34
+ attr_accessor :filter_fields
35
+
36
+ # Owner extractor — proc that extracts owner_id from a DynamoDB stream record
37
+ attr_accessor :owner_extractor
38
+
39
+ # Logger (defaults to $stdout)
40
+ attr_accessor :logger
41
+
42
+ # Searcher settings
43
+ attr_accessor :version_ttl, :max_results, :max_cached_dbs, :ephemeral_storage_mb
8
44
 
9
45
  def initialize
10
46
  @owner_key = 'user_id'
11
47
  @searchable_fields = %w[name description]
48
+ @token_field = 'searchTokens'
12
49
  @metadata_fields = %w[status created_at]
13
50
  @record_filter = ->(_record) { true }
14
- @owner_extractor = ->(stream_record) {
51
+ @filter_fields = %w[status]
52
+ @owner_extractor = lambda { |stream_record|
15
53
  image = stream_record.dig('dynamodb', 'NewImage') || stream_record.dig('dynamodb', 'OldImage') || {}
16
54
  image.dig(owner_key, 'S')
17
55
  }
@@ -22,11 +60,12 @@ module S3arch
22
60
  @logger = nil
23
61
  end
24
62
 
63
+ # Convenience: env-based configuration (reads from Lambda environment variables)
25
64
  def from_env!
26
- @source_table = ENV['S3ARCH_SOURCE_TABLE']
65
+ @source_table = ENV['S3ARCH_SOURCE_TABLE'] || ENV.fetch('INVENTORY_TABLE', nil)
27
66
  @source_index = ENV['S3ARCH_SOURCE_INDEX'] || 'UserIndex'
28
- @index_bucket = ENV['S3ARCH_INDEX_BUCKET']
29
- @version_table = ENV['S3ARCH_VERSION_TABLE']
67
+ @index_bucket = ENV['S3ARCH_INDEX_BUCKET'] || ENV.fetch('SEARCH_INDEX_BUCKET', nil)
68
+ @version_table = ENV['S3ARCH_VERSION_TABLE'] || ENV.fetch('SEARCH_INDEX_TABLE', nil)
30
69
  self
31
70
  end
32
71
 
@@ -4,8 +4,10 @@ require 'aws-sdk-dynamodb'
4
4
  require 'aws-sdk-s3'
5
5
  require 'sqlite3'
6
6
  require 'json'
7
-
8
7
  module S3arch
8
+ # Builds SQLite FTS5 databases per owner from pre-computed tokens stored in DynamoDB.
9
+ # The indexer never sees raw content — only tokens. Supports incremental updates via
10
+ # DynamoDB Stream events (INSERT/MODIFY/REMOVE).
9
11
  class Indexer
10
12
  def initialize(config: S3arch.configuration)
11
13
  config.validate!
@@ -14,6 +16,8 @@ module S3arch
14
16
  @s3 = Aws::S3::Client.new
15
17
  end
16
18
 
19
+ # Full rebuild — pulls all tokens from DynamoDB for an owner.
20
+ # Used for initial backfill or when incremental isn't possible.
17
21
  def rebuild(owner_id)
18
22
  records = fetch_records(owner_id)
19
23
  db_path = "/tmp/s3arch_#{owner_id}.sqlite3"
@@ -27,38 +31,265 @@ module S3arch
27
31
  File.delete(db_path) if db_path && File.exist?(db_path)
28
32
  end
29
33
 
34
+ # Incremental update — applies INSERT/DELETE/UPDATE to an existing index.
35
+ # Downloads current DB from S3, applies changes, re-uploads.
36
+ def apply_changes(owner_id, changes)
37
+ db_path = "/tmp/s3arch_#{owner_id}.sqlite3"
38
+ download_existing(owner_id, db_path)
39
+
40
+ unless File.exist?(db_path)
41
+ log(:info, 'No existing index, doing full rebuild', owner_id: owner_id)
42
+ return rebuild(owner_id)
43
+ end
44
+
45
+ db = SQLite3::Database.new(db_path)
46
+ db.results_as_hash = true
47
+
48
+ db.transaction do
49
+ changes.each { |change| apply_change(db, change) }
50
+ end
51
+
52
+ record_count = db.get_first_value('SELECT COUNT(*) FROM records_meta')
53
+ db.close
54
+
55
+ upload(owner_id, db_path)
56
+ increment_version(owner_id, record_count)
57
+
58
+ log(:info, 'Index updated incrementally', owner_id: owner_id, changes: changes.size, record_count: record_count)
59
+ ensure
60
+ File.delete(db_path) if db_path && File.exist?(db_path)
61
+ end
62
+
63
+ # Process SQS event containing DynamoDB stream records.
64
+ # Groups by owner and applies incremental changes.
30
65
  def process_event(event)
31
- records = event['Records'] || []
32
- owner_ids = records.filter_map { |r|
33
- body = JSON.parse(r['body'])
34
- @config.owner_extractor.call(body)
35
- }.uniq
66
+ sqs_records = event['Records'] || []
67
+ grouped = group_changes(sqs_records)
36
68
 
37
- log(:info, 'Rebuilding indexes', owner_ids: owner_ids, record_count: records.size)
38
- owner_ids.each { |id| rebuild(id) }
39
- { statusCode: 200, body: JSON.generate(rebuilt: owner_ids.size) }
69
+ log(:info, 'Processing stream events', owner_count: grouped.size, record_count: sqs_records.size)
70
+
71
+ grouped.each do |owner_id, changes|
72
+ if changes.any? { |c| c[:action] == :rebuild }
73
+ rebuild(owner_id)
74
+ else
75
+ apply_changes(owner_id, changes)
76
+ end
77
+ end
78
+
79
+ { statusCode: 200, body: JSON.generate(rebuilt: grouped.size) }
40
80
  end
41
81
 
82
+ RESERVED_WORDS = Set.new(%w[status name comment count size type]).freeze
83
+
42
84
  private
43
85
 
86
+ def group_changes(sqs_records)
87
+ grouped = Hash.new { |h, k| h[k] = [] }
88
+
89
+ sqs_records.each do |sqs_record|
90
+ stream_record = JSON.parse(sqs_record['body'])
91
+ event_name = stream_record['eventName']
92
+ new_image = stream_record.dig('dynamodb', 'NewImage')
93
+ old_image = stream_record.dig('dynamodb', 'OldImage')
94
+
95
+ owner_id = extract_owner(new_image || old_image)
96
+ next unless owner_id
97
+
98
+ change = build_change(event_name, new_image, old_image)
99
+ grouped[owner_id] << change if change
100
+ end
101
+
102
+ grouped
103
+ end
104
+
105
+ def build_change(event_name, new_image, old_image)
106
+ case event_name
107
+ when 'INSERT'
108
+ tokens = extract_tokens(new_image)
109
+ record_id = extract_record_id(new_image)
110
+ return nil unless tokens && record_id && passes_filter?(new_image)
111
+
112
+ { action: :insert, record_id: record_id, tokens: tokens, meta: extract_meta(new_image) }
113
+ when 'REMOVE'
114
+ tokens = extract_tokens(old_image)
115
+ record_id = extract_record_id(old_image)
116
+ return nil unless tokens && record_id
117
+
118
+ { action: :delete, record_id: record_id, tokens: tokens }
119
+ when 'MODIFY'
120
+ old_tokens = extract_tokens(old_image)
121
+ new_tokens = extract_tokens(new_image)
122
+ record_id = extract_record_id(new_image)
123
+ return nil unless record_id
124
+
125
+ # If item no longer passes filter, treat as delete
126
+ unless passes_filter?(new_image)
127
+ return old_tokens ? { action: :delete, record_id: record_id, tokens: old_tokens } : nil
128
+ end
129
+
130
+ # If item previously didn't pass filter (no old tokens), treat as insert
131
+ unless old_tokens
132
+ return new_tokens ? { action: :insert, record_id: record_id, tokens: new_tokens,
133
+ meta: extract_meta(new_image) } : nil
134
+ end
135
+
136
+ return nil unless new_tokens
137
+
138
+ { action: :update, record_id: record_id, old_tokens: old_tokens, new_tokens: new_tokens,
139
+ meta: extract_meta(new_image) }
140
+ end
141
+ end
142
+
143
+ def extract_owner(image)
144
+ return nil unless image
145
+
146
+ val = image[@config.owner_key]
147
+ val.is_a?(Hash) ? val['S'] : val
148
+ end
149
+
150
+ def extract_record_id(image)
151
+ return nil unless image
152
+
153
+ val = image['id']
154
+ val.is_a?(Hash) ? val['S'] : val
155
+ end
156
+
157
+ def extract_tokens(image)
158
+ return nil unless image
159
+
160
+ val = image[@config.token_field]
161
+ return nil unless val
162
+
163
+ # Token field is a DynamoDB Map: { "M": { "name": { "S": "..." }, "description": { "S": "..." } } }
164
+ if val.is_a?(Hash) && val.key?('M')
165
+ val['M'].transform_values { |v| v.is_a?(Hash) ? (v['S'] || '') : v.to_s }
166
+ elsif val.is_a?(Hash) && !val.key?('S')
167
+ val.transform_values { |v| v.is_a?(Hash) ? (v['S'] || '') : v.to_s }
168
+ end
169
+ end
170
+
171
+ def extract_meta(image)
172
+ @config.metadata_fields.each_with_object({}) do |field, meta|
173
+ val = image[field]
174
+ meta[field] = val.is_a?(Hash) ? (val['S'] || val['N'] || '') : val.to_s
175
+ end
176
+ end
177
+
178
+ def passes_filter?(image)
179
+ # Convert DynamoDB image to plain hash for filter
180
+ plain = image.transform_values { |v| v.is_a?(Hash) ? (v['S'] || v['N'] || v['BOOL']&.to_s || '') : v }
181
+ @config.record_filter.call(plain)
182
+ end
183
+
184
+ def apply_change(db, change)
185
+ case change[:action]
186
+ when :insert
187
+ rowid = next_rowid(db)
188
+ insert_row(db, rowid, change[:record_id], change[:tokens], change[:meta])
189
+ when :delete
190
+ rowid = find_rowid(db, change[:record_id])
191
+ delete_row(db, rowid, change[:tokens]) if rowid
192
+ when :update
193
+ rowid = find_rowid(db, change[:record_id])
194
+ if rowid
195
+ delete_row(db, rowid, change[:old_tokens])
196
+ insert_row(db, rowid, change[:record_id], change[:new_tokens], change[:meta])
197
+ else
198
+ # Record wasn't in index yet, just insert
199
+ new_rowid = next_rowid(db)
200
+ insert_row(db, new_rowid, change[:record_id], change[:new_tokens], change[:meta])
201
+ end
202
+ end
203
+ end
204
+
205
+ def insert_row(db, rowid, record_id, tokens, meta)
206
+ fts_cols = @config.searchable_fields
207
+ fts_values = fts_cols.map { |f| tokens[f] || '' }
208
+ placeholders = (['?'] * (fts_values.size + 1)).join(', ')
209
+ db.execute("INSERT INTO records_fts(rowid, #{fts_cols.join(', ')}) VALUES (#{placeholders})",
210
+ [rowid] + fts_values)
211
+
212
+ meta ||= {}
213
+ meta_values = [rowid, record_id] + @config.metadata_fields.map { |f| meta[f] || '' }
214
+ meta_placeholders = (['?'] * meta_values.size).join(', ')
215
+ meta_cols = "rowid, record_id, #{@config.metadata_fields.join(', ')}"
216
+ db.execute("INSERT INTO records_meta(#{meta_cols}) VALUES (#{meta_placeholders})", meta_values)
217
+ end
218
+
219
+ def delete_row(db, rowid, tokens)
220
+ fts_cols = @config.searchable_fields
221
+ fts_values = fts_cols.map { |f| tokens[f] || '' }
222
+ placeholders = (['?'] * (fts_values.size + 1)).join(', ')
223
+ # FTS5 contentless delete: INSERT with special 'delete' command
224
+ fts_delete_sql = "INSERT INTO records_fts(records_fts, rowid, #{fts_cols.join(', ')}) " \
225
+ "VALUES ('delete', #{placeholders})"
226
+ db.execute(fts_delete_sql, [rowid] + fts_values)
227
+ db.execute('DELETE FROM records_meta WHERE rowid = ?', [rowid])
228
+ end
229
+
230
+ def find_rowid(db, record_id)
231
+ db.get_first_value('SELECT rowid FROM records_meta WHERE record_id = ?', [record_id])
232
+ end
233
+
234
+ def next_rowid(db)
235
+ max = db.get_first_value('SELECT MAX(rowid) FROM records_meta')
236
+ (max || 0) + 1
237
+ end
238
+
239
+ def download_existing(owner_id, db_path)
240
+ @s3.get_object(bucket: @config.index_bucket, key: "#{owner_id}/index.sqlite3", response_target: db_path)
241
+ rescue Aws::S3::Errors::NoSuchKey
242
+ # No existing index — caller will fall back to rebuild
243
+ end
244
+
245
+ # Full rebuild: fetches token field from DynamoDB (never reads content)
44
246
  def fetch_records(owner_id)
45
247
  records = []
46
- params = { table_name: @config.source_table, index_name: @config.source_index,
47
- key_condition_expression: "#{@config.owner_key} = :owner",
48
- expression_attribute_values: { ':owner' => owner_id } }
248
+ params = build_query_params(owner_id)
49
249
 
50
250
  loop do
51
251
  result = @dynamodb.query(params)
52
- result.items.each { |item| records << item if @config.record_filter.call(item) }
252
+ result.items.each do |item|
253
+ next unless @config.record_filter.call(item)
254
+
255
+ tokens = item[@config.token_field]
256
+ next unless tokens.is_a?(Hash) && tokens.any?
257
+
258
+ records << { 'id' => item['id'], 'tokens' => tokens, 'meta' => extract_meta_from_item(item) }
259
+ end
53
260
  break unless result.last_evaluated_key
261
+
54
262
  params[:exclusive_start_key] = result.last_evaluated_key
55
263
  end
56
264
 
57
265
  records
58
266
  end
59
267
 
268
+ def build_query_params(owner_id)
269
+ fields = (['id', @config.token_field, @config.owner_key] +
270
+ @config.metadata_fields + @config.filter_fields).uniq
271
+ expression_names = {}
272
+ projected = fields.map { |f| reserved_word?(f) ? "##{f}".tap { |p| expression_names[p] = f } : f }
273
+
274
+ owner_placeholder = reserved_word?(@config.owner_key) ? "##{@config.owner_key}" : @config.owner_key
275
+ expression_names["##{@config.owner_key}"] = @config.owner_key if reserved_word?(@config.owner_key)
276
+
277
+ params = { table_name: @config.source_table, index_name: @config.source_index,
278
+ key_condition_expression: "#{owner_placeholder} = :owner",
279
+ expression_attribute_values: { ':owner' => owner_id },
280
+ projection_expression: projected.join(', ') }
281
+ params[:expression_attribute_names] = expression_names if expression_names.any?
282
+ params
283
+ end
284
+
285
+ def extract_meta_from_item(item)
286
+ @config.metadata_fields.to_h { |field| [field, item[field].to_s] }
287
+ end
288
+
289
+ def reserved_word?(field) = RESERVED_WORDS.include?(field.downcase)
290
+
60
291
  def build_database(db_path, records)
61
- File.delete(db_path) if File.exist?(db_path)
292
+ FileUtils.rm_f(db_path)
62
293
  db = SQLite3::Database.new(db_path)
63
294
 
64
295
  fts_cols = @config.searchable_fields.join(', ')
@@ -73,35 +304,21 @@ module S3arch
73
304
  db.transaction do
74
305
  records.each_with_index do |record, idx|
75
306
  rowid = idx + 1
76
- fts_values = @config.searchable_fields.map { |f| normalize_field(record[f]) }
77
- db.execute("INSERT INTO records_fts(rowid, #{fts_cols}) VALUES (#{(['?'] * (fts_values.size + 1)).join(', ')})",
78
- [rowid] + fts_values)
79
-
80
- meta_values = [rowid, normalize_field(record['id'])] + @config.metadata_fields.map { |f| normalize_field(record[f]) }
81
- placeholders = (['?'] * meta_values.size).join(', ')
82
- db.execute("INSERT INTO records_meta(rowid, record_id, #{@config.metadata_fields.join(', ')}) VALUES (#{placeholders})",
83
- meta_values)
307
+ tokens = record['tokens']
308
+ fts_values = @config.searchable_fields.map { |f| tokens[f] || '' }
309
+ placeholders = (['?'] * (fts_values.size + 1)).join(', ')
310
+ db.execute("INSERT INTO records_fts(rowid, #{fts_cols}) VALUES (#{placeholders})", [rowid] + fts_values)
311
+
312
+ meta_values = [rowid, record['id']] + @config.metadata_fields.map { |f| record['meta'][f] || '' }
313
+ meta_placeholders = (['?'] * meta_values.size).join(', ')
314
+ meta_cols = "rowid, record_id, #{@config.metadata_fields.join(', ')}"
315
+ db.execute("INSERT INTO records_meta(#{meta_cols}) VALUES (#{meta_placeholders})", meta_values)
84
316
  end
85
317
  end
86
318
 
87
319
  db.close
88
320
  end
89
321
 
90
- def normalize_field(value)
91
- case value
92
- when Hash
93
- if value.key?('S') then value['S'].to_s
94
- elsif value.key?('L') then value['L'].map { |v| v['S'] || v.to_s }.join(' ')
95
- elsif value.key?('N') then value['N'].to_s
96
- elsif value.key?('SS') then value['SS'].join(' ')
97
- else value.values.first.to_s
98
- end
99
- when Array then value.map { |v| v.is_a?(Hash) ? (v['S'] || v.to_s) : v.to_s }.join(' ')
100
- when nil then ''
101
- else value.to_s
102
- end
103
- end
104
-
105
322
  def upload(owner_id, db_path)
106
323
  @s3.put_object(bucket: @config.index_bucket, key: "#{owner_id}/index.sqlite3", body: File.open(db_path, 'rb'))
107
324
  end
@@ -110,13 +327,15 @@ module S3arch
110
327
  @dynamodb.update_item(
111
328
  table_name: @config.version_table,
112
329
  key: { @config.owner_key => owner_id },
113
- update_expression: 'SET version = if_not_exists(version, :zero) + :one, updated_at = :now, record_count = :count',
330
+ update_expression: 'SET version = if_not_exists(version, :zero) + :one, ' \
331
+ 'updated_at = :now, record_count = :count',
114
332
  expression_attribute_values: { ':zero' => 0, ':one' => 1, ':now' => Time.now.iso8601, ':count' => record_count }
115
333
  )
116
334
  end
117
335
 
118
336
  def log(level, message, **data)
119
337
  return unless @config.logger
338
+
120
339
  @config.logger.send(level, message, **data)
121
340
  end
122
341
  end
@@ -72,9 +72,7 @@ module S3arch
72
72
 
73
73
  def fetch_version(owner_id)
74
74
  cached = self.class.version_cache[owner_id]
75
- if cached && (Time.now - cached[:checked_at]) < @config.version_ttl
76
- return cached[:version]
77
- end
75
+ return cached[:version] if cached && (Time.now - cached[:checked_at]) < @config.version_ttl
78
76
 
79
77
  result = @dynamodb.get_item(table_name: @config.version_table,
80
78
  key: { @config.owner_key => owner_id },
@@ -112,6 +110,7 @@ module S3arch
112
110
  rows = db.execute(sql, [match_expr])
113
111
  rows.filter_map do |row|
114
112
  next if filters.any? { |field, value| row[field.to_s] != value }
113
+
115
114
  { record_id: row['record_id'], rank: row['rank'] }
116
115
  end
117
116
  rescue SQLite3::Exception => e
@@ -131,6 +130,7 @@ module S3arch
131
130
 
132
131
  def log(level, message, **data)
133
132
  return unless @config.logger
133
+
134
134
  @config.logger.send(level, message, **data)
135
135
  end
136
136
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3arch
4
+ # Pure tokenization — generates the token string stored in DynamoDB.
5
+ # At query time, FTS5 tokenizes the same way internally, so prefix matches work.
6
+ class Tokenizer
7
+ def initialize(fields: S3arch.configuration.searchable_fields)
8
+ @fields = fields
9
+ end
10
+
11
+ # Accepts a record hash, returns a hash of { field => tokenized_string }
12
+ # This is what gets stored in DynamoDB and fed directly into FTS5.
13
+ def tokenize(record)
14
+ @fields.to_h do |field|
15
+ [field, normalize(record[field])]
16
+ end
17
+ end
18
+
19
+ # Flattened single-string version for simple storage (all fields concatenated)
20
+ def tokenize_flat(record)
21
+ @fields.map { |f| normalize(record[f]) }.reject(&:empty?).join(' ')
22
+ end
23
+
24
+ private
25
+
26
+ def normalize(value)
27
+ case value
28
+ when Hash
29
+ if value.key?('S') then value['S'].to_s
30
+ elsif value.key?('L') then value['L'].map { |v| v['S'] || v.to_s }.join(' ')
31
+ elsif value.key?('N') then value['N'].to_s
32
+ elsif value.key?('SS') then value['SS'].join(' ')
33
+ else value.values.first.to_s
34
+ end
35
+ when Array then value.map { |v| v.is_a?(Hash) ? (v['S'] || v.to_s) : v.to_s }.join(' ')
36
+ when nil then ''
37
+ else value.to_s
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module S3arch
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.4'
5
5
  end
data/lib/s3arch.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 's3arch/version'
4
4
  require_relative 's3arch/configuration'
5
+ require_relative 's3arch/tokenizer'
5
6
  require_relative 's3arch/indexer'
6
7
  require_relative 's3arch/searcher'
7
8
  require_relative 's3arch/handler'
data.tar.gz.sig ADDED
Binary file
metadata CHANGED
@@ -1,12 +1,39 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: s3arch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Dalton
8
8
  bindir: bin
9
- cert_chain: []
9
+ cert_chain:
10
+ - |
11
+ -----BEGIN CERTIFICATE-----
12
+ MIIEdDCCAtygAwIBAgIBATANBgkqhkiG9w0BAQsFADBAMQ4wDAYDVQQDDAVhZ2Vu
13
+ dDEZMBcGCgmSJomT8ixkARkWCXN0b3d6aWxsYTETMBEGCgmSJomT8ixkARkWA2Nv
14
+ bTAeFw0yNjA2MDgxOTExNTlaFw0yNzA2MDgxOTExNTlaMEAxDjAMBgNVBAMMBWFn
15
+ ZW50MRkwFwYKCZImiZPyLGQBGRYJc3Rvd3ppbGxhMRMwEQYKCZImiZPyLGQBGRYD
16
+ Y29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAupBquKI/4WvXOgND
17
+ pXyqH2GllZs1wG4TWWdn/DoMg45UoCwD+AWEuGrIdInBCpPN8vEJNJWPoM/RrU+b
18
+ xRBZT4uUk00bnZRW2SYh5GJSqBoBR+rWc2DGkXyGfdRU2sQvkB0+is6ChgQ61WMM
19
+ 33LE9+loBlVsZ6EVtrc18Uh2OW0mJpe0hN2nmBrxZqqOZigxC4DKRMFHvpRkxSb6
20
+ mD4kit1AcwX9NEWJsXxrPaetL/SB/VbXaEZX93XAvp6USaXvCWt4slkDS2mIvqtn
21
+ 9DtGC43LFC7SDGbnsG9PVenQgVCi8UWFPUAab0PqZSlmi3Qlbhw8qTGPp5Cbv4vz
22
+ qjC2UGPOQigA/7lbbGRhCohMrjOVHMAQwkcgiIqtolUoYlnvPMIy+m3pdvgDv/PH
23
+ bsZGvXQ7i0458xsmp1vaKthZocVAR+GboHbuIiYPUnO45ccXUQ00x6365tTe7mZi
24
+ NvmUYdAGbQmVvFqyxF7IYA6sF74L2Lstu0knSfss557bAe1HAgMBAAGjeTB3MAkG
25
+ A1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSnxTL/lNBCeLqpeVIX6AUY
26
+ kel4zjAeBgNVHREEFzAVgRNhZ2VudEBzdG93emlsbGEuY29tMB4GA1UdEgQXMBWB
27
+ E2FnZW50QHN0b3d6aWxsYS5jb20wDQYJKoZIhvcNAQELBQADggGBACm9Fjit/UCv
28
+ FxlKqeiCTIG94cIx+QrWAOJSx9knKydwUec1u04D/DbfZjTn3C2Bj227QgxeUn+6
29
+ if3e2v7zAk1896hLmGYzML0+nxQPb0vmtdLR7HETUlSKTVabcv1fbwLyjsuGrBvk
30
+ y51vOEzUEZ508a9yepLYqrQu1kOju4d57c9oA5l3H0mMKWz7av9tFj0B+STvuaWk
31
+ HRYDWc5HgOEVTyV+w0uFt2Kw4OCb8C42uSvC5RfYYtw78MSP+5Ru+LXJ7XOtmuN0
32
+ E6GVmofQ17ig9O3rgfFbMendSInrRmvPIGswvM1yivq9NOllFbdck2OJKPx6FCJF
33
+ 7SJIkXQfc9P4B5iASIV1d1FsE0YX+g3jHXPJK/4mGL5bAyBKzpMfQB/mg6vQBzkh
34
+ aOKPwcreFj7TznBl89R5tNS9wZQfPVR98zgPyocddWhK18eQNMSBUnv4eeJ8PPbk
35
+ DovL+G8ajHDZ9fjH/+GVYHEMuiVdLarXrKJpHC1VfGTTUAp4NSEpUQ==
36
+ -----END CERTIFICATE-----
10
37
  date: 1980-01-02 00:00:00.000000000 Z
11
38
  dependencies:
12
39
  - !ruby/object:Gem::Dependency
@@ -62,11 +89,13 @@ files:
62
89
  - CHANGELOG.md
63
90
  - LICENSE.txt
64
91
  - README.md
92
+ - certs/stowzilla.pem
65
93
  - lib/s3arch.rb
66
94
  - lib/s3arch/configuration.rb
67
95
  - lib/s3arch/handler.rb
68
96
  - lib/s3arch/indexer.rb
69
97
  - lib/s3arch/searcher.rb
98
+ - lib/s3arch/tokenizer.rb
70
99
  - lib/s3arch/version.rb
71
100
  homepage: https://github.com/stowzilla/s3arch
72
101
  licenses:
@@ -74,7 +103,7 @@ licenses:
74
103
  metadata:
75
104
  rubygems_mfa_required: 'true'
76
105
  homepage_uri: https://github.com/stowzilla/s3arch
77
- source_code_uri: https://github.com/stowzilla/s3arch
106
+ source_code_uri: https://github.com/stowzilla/s3arch/tree/main
78
107
  changelog_uri: https://github.com/stowzilla/s3arch/blob/main/CHANGELOG.md
79
108
  rdoc_options: []
80
109
  require_paths:
@@ -90,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
119
  - !ruby/object:Gem::Version
91
120
  version: '0'
92
121
  requirements: []
93
- rubygems_version: 4.0.11
122
+ rubygems_version: 3.6.9
94
123
  specification_version: 4
95
124
  summary: SQLite FTS5 full-text search for DynamoDB on AWS Lambda
96
125
  test_files: []
metadata.gz.sig ADDED
@@ -0,0 +1,2 @@
1
+ �٣��b���A��t��1<ۈճ;w�@��^�����(,Z��\�r����XZƠ��B���:DewV�ԭ�h���x�KNI��%,`�Tf��8C��@��r;f��l}�7F�==T�V��b/!MP��ι[̦ @�ȁ��<�t�R���ȜX�uu�d�H��k�T��!��A�N~�_���^atOZ�p%h�{�n� ��fMT�Y_�'g��=�#���/���6!��TS(�|�l����7ݸ���{��
2
+ ��e~ʞ���uE���BG�S�7����ݼ�����*�q=�6r�_r��Q�P�|g�]�V]j��+ˍ���Qú�.B�������r