ask-session 0.1.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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ class Store
6
+ def initialize
7
+ @sessions = {}
8
+ @events = {}
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def create(id: nil, status: :active, metadata: {}, created_at: nil)
13
+ record = Record.create(id: id, status: status, metadata: metadata, created_at: created_at)
14
+ @mutex.synchronize do
15
+ if @sessions.key?(record.id)
16
+ raise DuplicateSessionError, "Session already exists: #{record.id}"
17
+ end
18
+ @sessions[record.id] = record
19
+ @events[record.id] = []
20
+ end
21
+ record
22
+ end
23
+
24
+ def load(id)
25
+ @mutex.synchronize { @sessions[id] }
26
+ end
27
+
28
+ def load!(id)
29
+ load(id) || raise(NotFoundError, "Session not found: #{id}")
30
+ end
31
+
32
+ def list
33
+ @mutex.synchronize { @sessions.values.dup }
34
+ end
35
+
36
+ def append_event(event, expected_sequence:)
37
+ @mutex.synchronize do
38
+ session_events = @events[event.session_id]
39
+ raise NotFoundError, "Session not found: #{event.session_id}" unless session_events
40
+
41
+ current_seq = session_events.size
42
+ unless current_seq == expected_sequence
43
+ raise ConcurrencyError,
44
+ "Expected sequence #{expected_sequence} but got #{current_seq} for session #{event.session_id}"
45
+ end
46
+
47
+ unless event.seq == current_seq + 1
48
+ raise ConcurrencyError,
49
+ "Event seq #{event.seq} does not match expected next sequence #{current_seq + 1} for session #{event.session_id}"
50
+ end
51
+
52
+ session_events << event
53
+ end
54
+ event
55
+ end
56
+
57
+ def state(session_id)
58
+ State.reduce(session_id, events(session_id))
59
+ end
60
+
61
+ def events_after(session_id, after_seq:)
62
+ @mutex.synchronize do
63
+ session_events = @events[session_id]
64
+ raise NotFoundError, "Session not found: #{session_id}" unless session_events
65
+
66
+ session_events.select { |e| e.seq > after_seq }.freeze
67
+ end
68
+ end
69
+
70
+ def current_sequence(session_id)
71
+ @mutex.synchronize do
72
+ session_events = @events[session_id]
73
+ raise NotFoundError, "Session not found: #{session_id}" unless session_events
74
+
75
+ session_events.size
76
+ end
77
+ end
78
+
79
+ def events(session_id)
80
+ @mutex.synchronize do
81
+ session_events = @events[session_id]
82
+ raise NotFoundError, "Session not found: #{session_id}" unless session_events
83
+
84
+ session_events.dup.freeze
85
+ end
86
+ end
87
+
88
+ def export(session_id = nil)
89
+ @mutex.synchronize do
90
+ if session_id
91
+ record = @sessions[session_id]
92
+ raise NotFoundError, "Session not found: #{session_id}" unless record
93
+
94
+ session_events = @events[session_id] || []
95
+ {
96
+ sessions: [record.to_h],
97
+ events: session_events.map(&:to_h)
98
+ }
99
+ else
100
+ {
101
+ sessions: @sessions.values.map(&:to_h),
102
+ events: @events.values.flatten.map(&:to_h)
103
+ }
104
+ end
105
+ end
106
+ end
107
+
108
+ def import(data)
109
+ sessions = data[:sessions] || data["sessions"] || []
110
+ events = data[:events] || data["events"] || []
111
+
112
+ @mutex.synchronize do
113
+ pending_sessions = {}
114
+ pending_events = {}
115
+
116
+ sessions.each do |s|
117
+ id = s[:id] || s["id"]
118
+ raise SerializationError, "Session record missing id" unless id && !id.to_s.empty?
119
+ raise DuplicateSessionError, "Session already exists: #{id}" if @sessions.key?(id)
120
+ raise DuplicateSessionError, "Duplicate session in import data: #{id}" if pending_sessions.key?(id)
121
+
122
+ record = Record.from_h(s)
123
+ pending_sessions[record.id] = record
124
+ pending_events[record.id] = []
125
+ end
126
+
127
+ events.each do |e|
128
+ session_id = e[:session_id] || e["session_id"]
129
+ unless pending_events.key?(session_id) || @events.key?(session_id)
130
+ raise NotFoundError, "Session not found: #{session_id}"
131
+ end
132
+
133
+ event = Event.from_h(e)
134
+ target = pending_events.key?(session_id) ? pending_events : @events
135
+ session_events = target[session_id]
136
+ expected_seq = session_events.size
137
+
138
+ unless event.seq == expected_seq + 1
139
+ raise ConcurrencyError,
140
+ "Expected sequence #{expected_seq + 1} but got #{event.seq} for session #{session_id}"
141
+ end
142
+
143
+ session_events << event
144
+ end
145
+
146
+ pending_sessions.each do |id, record|
147
+ @sessions[id] = record
148
+ @events[id] = pending_events[id]
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Ask
6
+ module Session
7
+ class Subscription
8
+ SENTINEL = Object.new
9
+
10
+ attr_reader :id, :session_id
11
+
12
+ def initialize(id:, session_id:)
13
+ @id = id
14
+ @session_id = session_id
15
+ @queue = Queue.new
16
+ @closed = false
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def closed?
21
+ @mutex.synchronize { @closed }
22
+ end
23
+
24
+ def close
25
+ @mutex.synchronize { @closed = true }
26
+ @queue.push(SENTINEL)
27
+ end
28
+
29
+ def next(timeout: nil)
30
+ return nil if closed?
31
+
32
+ if timeout
33
+ val = Timeout.timeout(timeout) { @queue.pop }
34
+ val.equal?(SENTINEL) ? nil : val
35
+ else
36
+ val = @queue.pop
37
+ val.equal?(SENTINEL) ? nil : val
38
+ end
39
+ rescue Timeout::Error
40
+ nil
41
+ end
42
+
43
+ alias_method :wait, :next
44
+
45
+ def each
46
+ while (event = self.next)
47
+ yield event
48
+ end
49
+ end
50
+
51
+ def enqueue(event)
52
+ @queue.push(event) unless closed?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+
7
+ require_relative "session/version"
8
+ require_relative "session/record"
9
+ require_relative "session/event"
10
+ require_relative "session/store"
11
+ require_relative "session/provider_store"
12
+ require_relative "session/state"
13
+ require_relative "session/codec"
14
+ require_relative "session/subscription"
15
+ require_relative "session/host"
16
+ require_relative "session/sink"
17
+
18
+ module Ask
19
+ module Session
20
+ class Error < StandardError; end
21
+ class ConcurrencyError < Error; end
22
+ class NotFoundError < Error; end
23
+ class DuplicateSessionError < Error; end
24
+ class SerializationError < Error; end
25
+ class InvalidTransitionError < Error; end
26
+ class SessionMismatchError < Error; end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/session"
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-session
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.25'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.25'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: Provides immutable session records, event envelopes, an in-memory store
41
+ with concurrency guardrails, a durable provider-backed store for restart-safe sessions,
42
+ a replayable host with subscriptions, a runtime tool-lifecycle sink, and a state
43
+ reducer that reconstructs session snapshots from event history.
44
+ email:
45
+ - kaka@myrrlabs.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/ask-session.rb
54
+ - lib/ask/session.rb
55
+ - lib/ask/session/codec.rb
56
+ - lib/ask/session/event.rb
57
+ - lib/ask/session/host.rb
58
+ - lib/ask/session/provider_store.rb
59
+ - lib/ask/session/record.rb
60
+ - lib/ask/session/sink.rb
61
+ - lib/ask/session/state.rb
62
+ - lib/ask/session/store.rb
63
+ - lib/ask/session/subscription.rb
64
+ - lib/ask/session/version.rb
65
+ homepage: https://github.com/ask-rb/ask-session
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/ask-rb/ask-session
70
+ source_code_uri: https://github.com/ask-rb/ask-session
71
+ changelog_uri: https://github.com/ask-rb/ask-session/blob/master/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.2'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.18
88
+ specification_version: 4
89
+ summary: Event-sourced session state for the ask-rb ecosystem.
90
+ test_files: []