legion-apollo 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd344e49282eb4e397ce81a5920980466dea27d9b23cf4156f549280f46af768
4
- data.tar.gz: 96b8d482992dbbe9d442bead192f128cf96dc776879cebd68e8b934b281c157e
3
+ metadata.gz: 7468f022e582731ace40da083285569d57c98e38ef375e5745f43d93e9c9d171
4
+ data.tar.gz: 3d2bb59a17a6060e690c7321fc914d2dc2e2bdd7bb1ec108917bf86fc7f26c72
5
5
  SHA512:
6
- metadata.gz: 35bc5fac20a5d153a54805d23a33218fed71177fe4b4f63724b527cc8fe3ca761912b617c0dc80380609d7eaa32a3197070525c9b08330f200343198ea01988d
7
- data.tar.gz: 6b84f74fd517e4500d389fe51d9bd7f149abd96ccd4eaac2d110899f82ed6856be775f5a55c104a0f59cb92a974ba9da6237bc1bce16c564bd1725e738c3de43
6
+ metadata.gz: a47cb6e18c47432ec0f3a15872f52dc193d7253fda94833da1c63d6046ad39bba8894f10dacc1c3472d7ef538157aad39cf1cf9894061623a9f1a162f69a5c16
7
+ data.tar.gz: 3ca7dc35c483089a4b2c6bb0af57b524afe3a3cce571b893a14a4aa01f74fbb01b51b5eb09040389db09a6b5f2516d0bd6d97fc499bf8c912aadc6c3b622a587
data/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.4.0] - 2026-04-02
4
+
5
+ ### Changed
6
+ - Adopt `Legion::Logging::Helper` across the Apollo core, local store, graph layer, message publishers, and helper modules
7
+ - Require `legion-logging >= 1.5.0` for structured helper-based logging support
8
+ - Remove unused top-level Apollo defaults for transport mode and timeout settings
9
+
10
+ ### Fixed
11
+ - Start addressing Apollo local lifecycle, merged routing, hydration contract, and route/runtime drift for the 0.4.x line
12
+ - `Apollo.shutdown` now shuts down `Apollo::Local`, and `Apollo::Local.upsert` refreshes expiry and embedding metadata for updated rows
13
+ - `Apollo::Local.hydrate_from_global` now accepts Apollo's `entries` response shape, and `query(scope: :all)` falls back to async global transport when no synchronous backends are available
14
+ - `apollo.enabled` now gates startup, `Apollo::Local.query_by_tags` filters in SQLite before applying limits, and message publish behavior has direct spec coverage
15
+ - Blank local queries now bypass invalid FTS `MATCH ''` calls while still supporting tag-filtered lookups
16
+ - REST query and ingest routes now delegate to `Legion::Apollo`, return `202` for async transport fallback, and stop reporting failed ingests as `201 Created`
17
+ - Apollo and Apollo::Local lifecycle, seeding, and hydration flows are now serialized, while local ingest/upsert keeps base rows and FTS updates inside the same transaction and treats duplicate content races as deterministic deduplication
18
+ - Apollo and Apollo::Local now normalize tag inputs consistently for direct Ruby callers, so ingest, query, and upsert semantics match the route-layer tag contract
19
+ - Apollo graph relationships now reject invalid relation types, deduplicate duplicate semantic edges with a unique index, delete entities transactionally, and traverse larger graphs with batched frontier expansion
20
+
21
+ ## [0.3.7] - 2026-03-31
4
22
 
5
23
  ### Added
6
24
  - `Apollo::Local.promote_to_global(tags:, min_confidence:)` — promotes local entries to Apollo Global
data/README.md CHANGED
@@ -46,6 +46,7 @@ Features:
46
46
  ```json
47
47
  {
48
48
  "apollo": {
49
+ "enabled": true,
49
50
  "default_limit": 5,
50
51
  "min_confidence": 0.3,
51
52
  "max_tags": 20,
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Apollo
5
7
  module Helpers
6
8
  # Confidence constants and predicate helpers for Apollo knowledge entries.
7
9
  # DB-dependent methods live in lex-apollo; only pure-function logic here.
8
10
  module Confidence
11
+ extend Legion::Logging::Helper
12
+
9
13
  INITIAL_CONFIDENCE = 0.5
10
14
  CORROBORATION_BOOST = 0.15
11
15
  CONTRADICTION_PENALTY = 0.20
@@ -43,7 +47,8 @@ module Legion
43
47
  return default unless defined?(Legion::Settings) && !Legion::Settings[:apollo].nil?
44
48
 
45
49
  Legion::Settings[:apollo][key] || default
46
- rescue StandardError
50
+ rescue StandardError => e
51
+ handle_exception(e, level: :debug, operation: 'apollo.helpers.confidence.apollo_setting', key: key)
47
52
  default
48
53
  end
49
54
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Apollo
5
7
  module Helpers
6
8
  # Pure cosine similarity math and match classification for Apollo vectors.
7
9
  module Similarity
10
+ extend Legion::Logging::Helper
11
+
8
12
  EXACT_MATCH_THRESHOLD = 0.95
9
13
  HIGH_SIMILARITY_THRESHOLD = 0.85
10
14
  CORROBORATION_THRESHOLD = 0.75
@@ -12,7 +16,7 @@ module Legion
12
16
 
13
17
  module_function
14
18
 
15
- def cosine_similarity(vec_a, vec_b) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
19
+ def cosine_similarity(vec_a, vec_b) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
16
20
  return 0.0 if vec_a.nil? || vec_b.nil? || vec_a.empty? || vec_b.empty?
17
21
  return 0.0 unless vec_a.size == vec_b.size
18
22
 
@@ -30,9 +34,18 @@ module Legion
30
34
 
31
35
  denom = Math.sqrt(mag_a) * Math.sqrt(mag_b)
32
36
  denom.zero? ? 0.0 : (dot / denom)
37
+ rescue StandardError => e
38
+ handle_exception(
39
+ e,
40
+ level: :debug,
41
+ operation: 'apollo.helpers.similarity.cosine_similarity',
42
+ vec_a_size: Array(vec_a).size,
43
+ vec_b_size: Array(vec_b).size
44
+ )
45
+ 0.0
33
46
  end
34
47
 
35
- def classify_match(similarity)
48
+ def classify_match(similarity) # rubocop:disable Metrics/MethodLength
36
49
  case similarity
37
50
  when EXACT_MATCH_THRESHOLD..1.0 then :exact
38
51
  when HIGH_SIMILARITY_THRESHOLD...EXACT_MATCH_THRESHOLD then :high
@@ -40,6 +53,14 @@ module Legion
40
53
  when RELATED_THRESHOLD...CORROBORATION_THRESHOLD then :related
41
54
  else :unrelated
42
55
  end
56
+ rescue StandardError => e
57
+ handle_exception(
58
+ e,
59
+ level: :debug,
60
+ operation: 'apollo.helpers.similarity.classify_match',
61
+ similarity: similarity
62
+ )
63
+ :unrelated
43
64
  end
44
65
  end
45
66
  end
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Apollo
5
7
  module Helpers
6
8
  # Pure-function tag normalization: lowercase, strip invalid chars, dedup, truncate.
7
9
  module TagNormalizer
10
+ extend Legion::Logging::Helper
11
+
8
12
  MAX_TAG_LENGTH = 64
9
13
  MAX_TAGS = 20
10
14
 
11
15
  module_function
12
16
 
13
- def normalize(tags)
17
+ def normalize(tags) # rubocop:disable Metrics/MethodLength
14
18
  return [] unless tags.is_a?(Array)
15
19
 
16
20
  tags
@@ -18,14 +22,30 @@ module Legion
18
22
  .compact
19
23
  .uniq
20
24
  .first(MAX_TAGS)
25
+ rescue StandardError => e
26
+ handle_exception(
27
+ e,
28
+ level: :debug,
29
+ operation: 'apollo.helpers.tag_normalizer.normalize',
30
+ tag_count: Array(tags).size
31
+ )
32
+ []
21
33
  end
22
34
 
23
- def normalize_one(tag)
35
+ def normalize_one(tag) # rubocop:disable Metrics/MethodLength
24
36
  return nil if tag.nil?
25
37
 
26
38
  normalized = tag.to_s.strip.downcase.gsub(/[^a-z0-9_:-]/, '_').squeeze('_')
27
39
  normalized = normalized[0, MAX_TAG_LENGTH] if normalized.length > MAX_TAG_LENGTH
28
40
  normalized.empty? ? nil : normalized
41
+ rescue StandardError => e
42
+ handle_exception(
43
+ e,
44
+ level: :debug,
45
+ operation: 'apollo.helpers.tag_normalizer.normalize_one',
46
+ tag_class: tag.class.to_s
47
+ )
48
+ nil
29
49
  end
30
50
  end
31
51
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'time'
4
5
 
5
6
  module Legion
@@ -8,25 +9,37 @@ module Legion
8
9
  # Entity-relationship graph layer backed by local SQLite tables.
9
10
  # Entities are schema-flexible (type + name + domain + JSON attributes).
10
11
  # Relationships are directional typed edges between two entities.
11
- # Graph traversal uses recursive SQLite CTEs (max_depth enforced).
12
+ # Graph traversal expands one frontier batch per depth to avoid per-node queries.
12
13
  module Graph # rubocop:disable Metrics/ModuleLength
13
14
  VALID_RELATION_TYPES = %w[AFFECTS OWNED_BY DEPENDS_ON RELATED_TO].freeze
14
15
 
15
16
  class << self # rubocop:disable Metrics/ClassLength
17
+ include Legion::Logging::Helper
18
+
16
19
  # --- Entity CRUD ---
17
20
 
18
21
  def create_entity(type:, name:, domain: nil, attributes: {}) # rubocop:disable Metrics/MethodLength
19
22
  now = timestamp
20
- id = db[:local_entities].insert(
21
- entity_type: type.to_s,
22
- name: name.to_s,
23
- domain: domain&.to_s,
24
- attributes: encode(attributes),
25
- created_at: now,
26
- updated_at: now
27
- )
23
+ id = db.transaction do
24
+ db[:local_entities].insert(
25
+ entity_type: type.to_s,
26
+ name: name.to_s,
27
+ domain: domain&.to_s,
28
+ attributes: encode(attributes),
29
+ created_at: now,
30
+ updated_at: now
31
+ )
32
+ end
33
+ log.info { "Apollo::Local::Graph created entity id=#{id} type=#{type} name=#{name}" }
28
34
  { success: true, id: id }
29
35
  rescue Sequel::Error => e
36
+ handle_exception(
37
+ e,
38
+ level: :error,
39
+ operation: 'apollo.local.graph.create_entity',
40
+ entity_type: type,
41
+ name: name
42
+ )
30
43
  { success: false, error: e.message }
31
44
  end
32
45
 
@@ -34,26 +47,44 @@ module Legion
34
47
  row = db[:local_entities].where(id: id).first
35
48
  return { success: false, error: :not_found } unless row
36
49
 
50
+ log.debug { "Apollo::Local::Graph found entity id=#{id}" }
37
51
  { success: true, entity: decode_entity(row) }
38
52
  rescue Sequel::Error => e
53
+ handle_exception(e, level: :error, operation: 'apollo.local.graph.find_entity', entity_id: id)
39
54
  { success: false, error: e.message }
40
55
  end
41
56
 
42
- def find_entities_by_type(type:, limit: 50)
57
+ def find_entities_by_type(type:, limit: 50) # rubocop:disable Metrics/MethodLength
43
58
  rows = db[:local_entities].where(entity_type: type.to_s).limit(limit).all
59
+ log.debug { "Apollo::Local::Graph found entities type=#{type} count=#{rows.size}" }
44
60
  { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
45
61
  rescue Sequel::Error => e
62
+ handle_exception(
63
+ e,
64
+ level: :error,
65
+ operation: 'apollo.local.graph.find_entities_by_type',
66
+ entity_type: type,
67
+ limit: limit
68
+ )
46
69
  { success: false, error: e.message }
47
70
  end
48
71
 
49
- def find_entities_by_name(name:, limit: 50)
72
+ def find_entities_by_name(name:, limit: 50) # rubocop:disable Metrics/MethodLength
50
73
  rows = db[:local_entities].where(name: name.to_s).limit(limit).all
74
+ log.debug { "Apollo::Local::Graph found entities name=#{name} count=#{rows.size}" }
51
75
  { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
52
76
  rescue Sequel::Error => e
77
+ handle_exception(
78
+ e,
79
+ level: :error,
80
+ operation: 'apollo.local.graph.find_entities_by_name',
81
+ name: name,
82
+ limit: limit
83
+ )
53
84
  { success: false, error: e.message }
54
85
  end
55
86
 
56
- def update_entity(id:, **fields)
87
+ def update_entity(id:, **fields) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
57
88
  now = timestamp
58
89
  updates = fields.slice(:entity_type, :name, :domain).transform_values(&:to_s)
59
90
  updates[:attributes] = encode(fields[:attributes]) if fields.key?(:attributes)
@@ -62,40 +93,71 @@ module Legion
62
93
  count = db[:local_entities].where(id: id).update(updates)
63
94
  return { success: false, error: :not_found } if count.zero?
64
95
 
96
+ log.info { "Apollo::Local::Graph updated entity id=#{id}" }
65
97
  { success: true, id: id }
66
98
  rescue Sequel::Error => e
99
+ handle_exception(
100
+ e,
101
+ level: :error,
102
+ operation: 'apollo.local.graph.update_entity',
103
+ entity_id: id,
104
+ fields: fields.keys
105
+ )
67
106
  { success: false, error: e.message }
68
107
  end
69
108
 
70
109
  def delete_entity(id:)
71
- db[:local_relationships].where(source_entity_id: id).delete
72
- db[:local_relationships].where(target_entity_id: id).delete
73
- count = db[:local_entities].where(id: id).delete
74
- return { success: false, error: :not_found } if count.zero?
110
+ result = delete_entity_transaction(id)
111
+ return result unless result[:success]
75
112
 
76
- { success: true, id: id }
113
+ log.info { "Apollo::Local::Graph deleted entity id=#{id}" }
114
+ result
77
115
  rescue Sequel::Error => e
116
+ handle_exception(e, level: :error, operation: 'apollo.local.graph.delete_entity', entity_id: id)
78
117
  { success: false, error: e.message }
79
118
  end
80
119
 
81
120
  # --- Relationship CRUD ---
82
121
 
83
122
  def create_relationship(source_id:, target_id:, relation_type:, attributes: {}) # rubocop:disable Metrics/MethodLength
84
- now = timestamp
85
- id = db[:local_relationships].insert(
86
- source_entity_id: source_id,
87
- target_entity_id: target_id,
88
- relation_type: relation_type.to_s.upcase,
89
- attributes: encode(attributes),
90
- created_at: now,
91
- updated_at: now
92
- )
123
+ normalized_relation_type = normalize_relation_type(relation_type)
124
+ return invalid_relation_type_error(relation_type) unless normalized_relation_type
125
+
126
+ existing = existing_relationship(source_id, target_id, normalized_relation_type)
127
+ return deduplicated_relationship(existing) if existing
128
+
129
+ id = insert_relationship(source_id, target_id, normalized_relation_type, attributes)
130
+ log.info do
131
+ "Apollo::Local::Graph created relationship id=#{id} source_id=#{source_id} " \
132
+ "target_id=#{target_id} relation_type=#{normalized_relation_type}"
133
+ end
93
134
  { success: true, id: id }
135
+ rescue Sequel::UniqueConstraintViolation => e
136
+ duplicate = handle_duplicate_relationship(source_id, target_id, normalized_relation_type)
137
+ return duplicate if duplicate
138
+
139
+ handle_exception(
140
+ e,
141
+ level: :error,
142
+ operation: 'apollo.local.graph.create_relationship',
143
+ source_id: source_id,
144
+ target_id: target_id,
145
+ relation_type: normalized_relation_type
146
+ )
147
+ { success: false, error: e.message }
94
148
  rescue Sequel::Error => e
149
+ handle_exception(
150
+ e,
151
+ level: :error,
152
+ operation: 'apollo.local.graph.create_relationship',
153
+ source_id: source_id,
154
+ target_id: target_id,
155
+ relation_type: relation_type
156
+ )
95
157
  { success: false, error: e.message }
96
158
  end
97
159
 
98
- def find_relationships(entity_id:, relation_type: nil, direction: :outbound)
160
+ def find_relationships(entity_id:, relation_type: nil, direction: :outbound) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
99
161
  ds = case direction
100
162
  when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
101
163
  when :both then relationship_both_directions(entity_id)
@@ -103,24 +165,39 @@ module Legion
103
165
  end
104
166
  ds = ds.where(relation_type: relation_type.to_s.upcase) if relation_type
105
167
  rows = ds.all
168
+ log.debug do
169
+ "Apollo::Local::Graph found relationships entity_id=#{entity_id} direction=#{direction} " \
170
+ "relation_type=#{relation_type || 'any'} count=#{rows.size}"
171
+ end
106
172
  { success: true, relationships: rows.map { |r| decode_relationship(r) }, count: rows.size }
107
173
  rescue Sequel::Error => e
174
+ handle_exception(
175
+ e,
176
+ level: :error,
177
+ operation: 'apollo.local.graph.find_relationships',
178
+ entity_id: entity_id,
179
+ relation_type: relation_type,
180
+ direction: direction
181
+ )
108
182
  { success: false, error: e.message }
109
183
  end
110
184
 
111
185
  def delete_relationship(id:)
112
- count = db[:local_relationships].where(id: id).delete
186
+ count = db.transaction { db[:local_relationships].where(id: id).delete }
113
187
  return { success: false, error: :not_found } if count.zero?
114
188
 
189
+ log.info { "Apollo::Local::Graph deleted relationship id=#{id}" }
115
190
  { success: true, id: id }
116
191
  rescue Sequel::Error => e
192
+ handle_exception(e, level: :error, operation: 'apollo.local.graph.delete_relationship', relationship_id: id)
117
193
  { success: false, error: e.message }
118
194
  end
119
195
 
120
196
  # --- Graph Traversal ---
121
197
 
122
198
  # Traverse from an entity following edges of the given relation_type.
123
- # Returns all reachable entities within max_depth hops using a recursive CTE.
199
+ # Returns all reachable entities within max_depth hops by expanding one
200
+ # frontier batch per depth level instead of querying neighbors per node.
124
201
  #
125
202
  # @param entity_id [Integer] starting entity id
126
203
  # @param relation_type [String, nil] filter by edge type (nil = any)
@@ -141,50 +218,135 @@ module Legion
141
218
  count: entity_rows.size
142
219
  }
143
220
  rescue Sequel::Error => e
221
+ handle_exception(
222
+ e,
223
+ level: :error,
224
+ operation: 'apollo.local.graph.traverse',
225
+ entity_id: entity_id,
226
+ relation_type: relation_type,
227
+ depth: depth,
228
+ direction: direction
229
+ )
144
230
  { success: false, error: e.message }
145
231
  end
146
232
 
147
233
  private
148
234
 
149
- def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
150
- visited = Set.new
235
+ def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength
236
+ visited = Set.new([start_id])
151
237
  frontier = [start_id]
152
238
  edge_rows = []
153
239
 
154
- # Run max_depth + 1 passes so that depth=1 means "start node + its direct neighbors".
155
- # Pass 0 visits the start node and pushes its neighbors into the next frontier.
156
- # Passes 1..max_depth each follow one more hop.
157
- (max_depth + 1).times do
240
+ max_depth.times do
158
241
  break if frontier.empty?
159
242
 
160
- next_frontier = []
161
- frontier.each do |current_id|
162
- next if visited.include?(current_id)
163
-
164
- visited.add(current_id)
165
- neighbors, edges = fetch_neighbors(current_id, rel_filter, direction)
166
- edge_rows.concat(edges)
167
- neighbors.each { |n| next_frontier << n unless visited.include?(n) }
168
- end
169
- frontier = next_frontier
243
+ rows = fetch_frontier_edges(frontier, rel_filter, direction)
244
+ edge_rows.concat(rows)
245
+ frontier = next_frontier_ids(rows, direction).reject { |neighbor_id| visited.include?(neighbor_id) }.uniq
246
+ frontier.each { |neighbor_id| visited.add(neighbor_id) }
170
247
  end
171
248
 
172
249
  [visited.to_a, edge_rows.uniq { |r| r[:id] }]
173
250
  end
174
251
 
175
- def fetch_neighbors(entity_id, rel_filter, direction)
252
+ def fetch_frontier_edges(frontier, rel_filter, direction)
176
253
  ds = case direction
177
- when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
178
- else db[:local_relationships].where(source_entity_id: entity_id)
254
+ when :inbound then db[:local_relationships].where(target_entity_id: frontier)
255
+ else db[:local_relationships].where(source_entity_id: frontier)
179
256
  end
180
257
  ds = ds.where(relation_type: rel_filter) if rel_filter
181
- rows = ds.all
258
+ ds.all
259
+ end
182
260
 
183
- neighbor_ids = rows.map do |r|
184
- direction == :inbound ? r[:source_entity_id] : r[:target_entity_id]
261
+ def next_frontier_ids(rows, direction)
262
+ rows.map do |row|
263
+ direction == :inbound ? row[:source_entity_id] : row[:target_entity_id]
185
264
  end
265
+ end
266
+
267
+ def normalize_relation_type(relation_type)
268
+ normalized = relation_type.to_s.upcase
269
+ return normalized if VALID_RELATION_TYPES.include?(normalized)
270
+
271
+ nil
272
+ end
273
+
274
+ def invalid_relation_type_error(relation_type)
275
+ log.warn { "Apollo::Local::Graph rejected invalid relation_type=#{relation_type}" }
276
+ { success: false, error: :invalid_relation_type }
277
+ end
278
+
279
+ def existing_relationship(source_id, target_id, relation_type)
280
+ db[:local_relationships].where(
281
+ source_entity_id: source_id,
282
+ target_entity_id: target_id,
283
+ relation_type: relation_type
284
+ ).first
285
+ end
286
+
287
+ def deduplicated_relationship(existing)
288
+ log.info do
289
+ "Apollo::Local::Graph deduplicated relationship id=#{existing[:id]} " \
290
+ "relation_type=#{existing[:relation_type]}"
291
+ end
292
+ { success: true, id: existing[:id], mode: :deduplicated }
293
+ end
294
+
295
+ def insert_relationship(source_id, target_id, relation_type, attributes)
296
+ db.transaction do
297
+ db[:local_relationships].insert(relationship_row(source_id, target_id, relation_type, attributes))
298
+ end
299
+ end
300
+
301
+ def relationship_row(source_id, target_id, relation_type, attributes)
302
+ now = timestamp
303
+ {
304
+ source_entity_id: source_id,
305
+ target_entity_id: target_id,
306
+ relation_type: relation_type,
307
+ attributes: encode(attributes),
308
+ created_at: now,
309
+ updated_at: now
310
+ }
311
+ end
312
+
313
+ def handle_duplicate_relationship(source_id, target_id, relation_type)
314
+ existing = existing_relationship(source_id, target_id, relation_type)
315
+ return deduplicated_relationship(existing) if existing
316
+
317
+ nil
318
+ end
319
+
320
+ def delete_entity_transaction(id)
321
+ result = nil
322
+ db.transaction do
323
+ result = existing_entity?(id) ? delete_existing_entity(id) : missing_entity_result
324
+ raise Sequel::Rollback unless result[:success]
325
+ end
326
+ result
327
+ end
328
+
329
+ def existing_entity?(id)
330
+ !db[:local_entities].where(id: id).first.nil?
331
+ end
332
+
333
+ def delete_existing_entity(id)
334
+ delete_entity_relationships(id)
335
+ delete_entity_row(id)
336
+ { success: true, id: id }
337
+ end
338
+
339
+ def missing_entity_result
340
+ { success: false, error: :not_found }
341
+ end
342
+
343
+ def delete_entity_relationships(id)
344
+ db[:local_relationships].where(source_entity_id: id).delete
345
+ db[:local_relationships].where(target_entity_id: id).delete
346
+ end
186
347
 
187
- [neighbor_ids, rows]
348
+ def delete_entity_row(id)
349
+ db[:local_entities].where(id: id).delete
188
350
  end
189
351
 
190
352
  def relationship_both_directions(entity_id)
@@ -206,7 +368,8 @@ module Legion
206
368
  return '{}' if obj.nil? || obj.empty?
207
369
 
208
370
  Legion::JSON.dump(obj)
209
- rescue StandardError
371
+ rescue StandardError => e
372
+ handle_exception(e, level: :debug, operation: 'apollo.local.graph.encode')
210
373
  '{}'
211
374
  end
212
375
 
@@ -214,7 +377,8 @@ module Legion
214
377
  return {} if json_str.nil? || json_str.strip.empty?
215
378
 
216
379
  Legion::JSON.parse(json_str, symbolize_names: true)
217
- rescue StandardError
380
+ rescue StandardError => e
381
+ handle_exception(e, level: :debug, operation: 'apollo.local.graph.decode')
218
382
  {}
219
383
  end
220
384
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ run <<~SQL
6
+ DELETE FROM local_relationships
7
+ WHERE id NOT IN (
8
+ SELECT MIN(id)
9
+ FROM local_relationships
10
+ GROUP BY source_entity_id, target_entity_id, relation_type
11
+ )
12
+ SQL
13
+
14
+ run <<~SQL
15
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_local_rel_unique
16
+ ON local_relationships (source_entity_id, target_entity_id, relation_type)
17
+ SQL
18
+ end
19
+
20
+ down do
21
+ run 'DROP INDEX IF EXISTS idx_local_rel_unique'
22
+ end
23
+ end