Class: RailsMarkup::Store

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_markup/store.rb

Overview

In-memory store for sessions and annotations. Ephemeral by design — data lives for one coding session.

Defined Under Namespace

Classes: Annotation, CapacityError, Session, ValidationError

Constant Summary collapse

MAX_SESSIONS =
100
SESSION_TTL =

4 hours

4 * 3600
MAX_ANNOTATIONS_PER_SESSION =
1_000
MAX_ANNOTATION_BYTES =
25_000_000
MAX_CONTENT_BYTES =
5_000
MAX_TARGET_BYTES =
16_384
MAX_SELECTED_TEXT_BYTES =
2_000
MAX_METADATA_BYTES =
65_536
MAX_THREAD_MESSAGE_BYTES =
5_000
INTENTS =
%w[fix change question approve].freeze
SEVERITIES =
%w[suggestion important blocking].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION, max_annotation_bytes: MAX_ANNOTATION_BYTES) ⇒ Store

Returns a new instance of Store.



31
32
33
34
35
36
37
38
39
# File 'lib/rails_markup/store.rb', line 31

def initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION,
               max_annotation_bytes: MAX_ANNOTATION_BYTES)
  @sessions          = {}
  @annotations_index = {} # id -> annotation (O(1) lookup)
  @subscribers       = [] # SSE callbacks: [session_id, callback]
  @mutex             = Mutex.new
  @max_annotations_per_session = max_annotations_per_session
  @max_annotation_bytes = max_annotation_bytes
end

Instance Attribute Details

#sessionsObject (readonly)

Returns the value of attribute sessions.



29
30
31
# File 'lib/rails_markup/store.rb', line 29

def sessions
  @sessions
end

Instance Method Details

#acknowledge(annotation_id) ⇒ Object

--- Status transitions ---



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/rails_markup/store.rb', line 122

def acknowledge(annotation_id)
  @mutex.synchronize do
    ann = @annotations_index[annotation_id]
    return nil unless ann
    return ann if ann.status == "acknowledged"

    validate_transition!(ann, "acknowledged", from: %w[pending])
    ann.status = "acknowledged"
    ann
  end
end

#all_pendingObject



114
115
116
117
118
# File 'lib/rails_markup/store.rb', line 114

def all_pending
  @mutex.synchronize do
    @sessions.values.flat_map { |s| s.annotations.select { |a| a.status == "pending" } }
  end
end

#create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion", selected_text: nil, metadata: {}) ⇒ Object

--- Annotations ---



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/rails_markup/store.rb', line 69

def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
                      selected_text: nil, metadata: {})
  annotation_bytes = validate_annotation!(
    target:, content:, intent:, severity:, selected_text:, metadata:
  )
  id = SecureRandom.hex(8)
  annotation = Annotation.new(
    id: id,
    session_id: session_id,
    target: target,
    content: content,
    intent: intent,
    severity: severity,
    status: "pending",
    selected_text: selected_text,
    metadata:  || {},
    created_at: Time.now.iso8601,
    thread: []
  )

  # Single mutex block — no TOCTOU gap
  @mutex.synchronize do
    session = @sessions[session_id]
    return nil unless session

    enforce_session_capacity!(session, annotation_bytes)
    session.annotations << annotation
    @annotations_index[id] = annotation
  end

  notify(session_id, type: "annotation_created", annotation: serialize_annotation(annotation))
  annotation
end

#create_session(url:, metadata: {}) ⇒ Object

--- Sessions ---



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/rails_markup/store.rb', line 43

def create_session(url:, metadata: {})
  id = SecureRandom.hex(8)
  session = Session.new(
    id: id,
    url: url,
    metadata:  || {},
    created_at: Time.now.iso8601,
    annotations: []
  )
  @mutex.synchronize do
    evict_stale_sessions
    @sessions[id] = session
  end
  session
end

#dismiss(annotation_id, reason: nil) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/rails_markup/store.rb', line 154

def dismiss(annotation_id, reason: nil)
  changed = false
  ann = @mutex.synchronize do
    annotation = @annotations_index[annotation_id]
    return nil unless annotation
    return annotation if annotation.status == "dismissed"

    validate_transition!(annotation, "dismissed", from: %w[pending acknowledged])
    append_thread_message!(annotation, reason) unless reason.nil? || reason == ""
    annotation.status = "dismissed"
    changed = true
    annotation
  end
  return ann unless changed

  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
                         status: "dismissed", reason: reason)
  ann
end

#get_annotation(annotation_id) ⇒ Object



103
104
105
# File 'lib/rails_markup/store.rb', line 103

def get_annotation(annotation_id)
  @mutex.synchronize { @annotations_index[annotation_id] }
end

#get_session(id) ⇒ Object



59
60
61
# File 'lib/rails_markup/store.rb', line 59

def get_session(id)
  @mutex.synchronize { @sessions[id] }
end

#list_sessionsObject



63
64
65
# File 'lib/rails_markup/store.rb', line 63

def list_sessions
  @mutex.synchronize { @sessions.values }
end

#pending_for_session(session_id) ⇒ Object



107
108
109
110
111
112
# File 'lib/rails_markup/store.rb', line 107

def pending_for_session(session_id)
  session = get_session(session_id)
  return [] unless session

  @mutex.synchronize { session.annotations.select { |a| a.status == "pending" } }
end

#reply(annotation_id, message:) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/rails_markup/store.rb', line 174

def reply(annotation_id, message:)
  ann = @mutex.synchronize do
    annotation = @annotations_index[annotation_id]
    return nil unless annotation

    append_thread_message!(annotation, message)
    annotation
  end
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
                         status: ann.status, message: message)
  ann
end

#resolve(annotation_id, summary: nil) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/rails_markup/store.rb', line 134

def resolve(annotation_id, summary: nil)
  changed = false
  ann = @mutex.synchronize do
    annotation = @annotations_index[annotation_id]
    return nil unless annotation
    return annotation if annotation.status == "resolved"

    validate_transition!(annotation, "resolved", from: %w[pending acknowledged])
    append_thread_message!(annotation, summary) unless summary.nil? || summary == ""
    annotation.status = "resolved"
    changed = true
    annotation
  end
  return ann unless changed

  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
                         status: "resolved", summary: summary)
  ann
end

#serialize_annotation(ann) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/rails_markup/store.rb', line 215

def serialize_annotation(ann)
  {
    id: ann.id,
    sessionId: ann.session_id,
    target: ann.target,
    content: ann.content,
    intent: ann.intent,
    severity: ann.severity,
    status: ann.status,
    selectedText: ann.selected_text,
    authorName: ann.&.dig("author"),
    metadata: ann.,
    createdAt: ann.created_at,
    thread: ann.thread
  }
end

#serialize_session(session) ⇒ Object

--- Serialization ---



205
206
207
208
209
210
211
212
213
# File 'lib/rails_markup/store.rb', line 205

def serialize_session(session)
  {
    id: session.id,
    url: session.url,
    metadata: session.,
    createdAt: session.created_at,
    annotations: session.annotations.map { |a| serialize_annotation(a) }
  }
end

#subscribe(session_id, &callback) ⇒ Object

--- SSE subscriptions ---



189
190
191
192
193
# File 'lib/rails_markup/store.rb', line 189

def subscribe(session_id, &callback)
  sub = [session_id, callback]
  @mutex.synchronize { @subscribers << sub }
  sub
end

#supports_subscriptions?Boolean

Returns:

  • (Boolean)


199
200
201
# File 'lib/rails_markup/store.rb', line 199

def supports_subscriptions?
  true
end

#unsubscribe(sub) ⇒ Object



195
196
197
# File 'lib/rails_markup/store.rb', line 195

def unsubscribe(sub)
  @mutex.synchronize { @subscribers.delete(sub) }
end