durable_streams 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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableStreams
4
+ # HTTP header names
5
+ STREAM_NEXT_OFFSET_HEADER = "stream-next-offset"
6
+ STREAM_UP_TO_DATE_HEADER = "stream-up-to-date"
7
+ STREAM_CURSOR_HEADER = "stream-cursor"
8
+ STREAM_TTL_HEADER = "stream-ttl"
9
+ STREAM_EXPIRES_AT_HEADER = "stream-expires-at"
10
+ STREAM_SEQ_HEADER = "stream-seq"
11
+ PRODUCER_ID_HEADER = "producer-id"
12
+ PRODUCER_EPOCH_HEADER = "producer-epoch"
13
+ PRODUCER_SEQ_HEADER = "producer-seq"
14
+ PRODUCER_EXPECTED_SEQ_HEADER = "producer-expected-seq"
15
+ PRODUCER_RECEIVED_SEQ_HEADER = "producer-received-seq"
16
+ STREAM_CLOSED_HEADER = "stream-closed"
17
+
18
+ # Result from HEAD request
19
+ # next_offset: The tail offset (position after last byte, where next append goes)
20
+ # stream_closed: Whether the stream has been closed (no more appends allowed)
21
+ HeadResult = Struct.new(:exists, :content_type, :next_offset, :etag, :cache_control, :stream_closed, keyword_init: true) do
22
+ def initialize(**)
23
+ super
24
+ self.stream_closed = false if stream_closed.nil?
25
+ freeze
26
+ end
27
+
28
+ def exists?
29
+ exists
30
+ end
31
+
32
+ def closed?
33
+ stream_closed
34
+ end
35
+ end
36
+
37
+ # Result from close operation
38
+ # final_offset: The final offset after closing (position after any appended data)
39
+ CloseResult = Struct.new(:final_offset, keyword_init: true) do
40
+ def initialize(**)
41
+ super
42
+ freeze
43
+ end
44
+ end
45
+
46
+ # Result from append
47
+ # next_offset: The new tail offset after this append (for checkpointing)
48
+ AppendResult = Struct.new(:next_offset, :duplicate, keyword_init: true) do
49
+ def initialize(**)
50
+ super
51
+ freeze
52
+ end
53
+
54
+ def duplicate?
55
+ duplicate || false
56
+ end
57
+ end
58
+
59
+ # A batch of JSON messages with metadata
60
+ # next_offset: Position to resume from (pass to next read)
61
+ JsonBatch = Struct.new(:items, :next_offset, :cursor, :up_to_date, keyword_init: true) do
62
+ def initialize(items: [], **)
63
+ super(items: Array(items).freeze, **)
64
+ freeze
65
+ end
66
+
67
+ def up_to_date?
68
+ up_to_date || false
69
+ end
70
+ end
71
+
72
+ # A byte chunk (for non-JSON streams)
73
+ # next_offset: Position to resume from (pass to next read)
74
+ ByteChunk = Struct.new(:data, :next_offset, :cursor, :up_to_date, keyword_init: true) do
75
+ def initialize(**)
76
+ super
77
+ freeze
78
+ end
79
+
80
+ def up_to_date?
81
+ up_to_date || false
82
+ end
83
+ end
84
+
85
+ # Retry policy configuration (not frozen - may be modified before use)
86
+ RetryPolicy = Struct.new(:max_retries, :initial_delay, :max_delay, :multiplier, :retryable_statuses,
87
+ keyword_init: true) do
88
+ def self.default
89
+ new(
90
+ max_retries: 5,
91
+ initial_delay: 0.1,
92
+ max_delay: 30.0,
93
+ multiplier: 2.0,
94
+ retryable_statuses: [429, 500, 502, 503, 504].freeze
95
+ ).freeze
96
+ end
97
+ end
98
+
99
+ # Producer append result (includes epoch/seq for exactly-once tracking)
100
+ ProducerResult = Struct.new(:next_offset, :duplicate, :epoch, :seq, keyword_init: true) do
101
+ def initialize(**)
102
+ super
103
+ freeze
104
+ end
105
+
106
+ def duplicate?
107
+ duplicate || false
108
+ end
109
+ end
110
+
111
+ # Check if content type is JSON (supports vendor types like application/vnd.foo+json)
112
+ def self.json_content_type?(content_type)
113
+ return false if content_type.nil?
114
+
115
+ normalized = content_type.split(";").first&.strip&.downcase
116
+ normalized == "application/json" || normalized&.end_with?("+json")
117
+ end
118
+
119
+ # Check if content type supports SSE
120
+ def self.sse_compatible?(content_type)
121
+ return false if content_type.nil?
122
+
123
+ normalized = content_type.split(";").first&.strip&.downcase
124
+ normalized == "application/json" || normalized&.start_with?("text/")
125
+ end
126
+
127
+ # Normalize offset to a valid string (defaults to "-1" for empty/nil)
128
+ def self.normalize_offset(offset)
129
+ offset.nil? || offset.to_s.empty? ? "-1" : offset.to_s
130
+ end
131
+
132
+ # Parse common response headers for stream metadata
133
+ # @param response [HTTP::Response] HTTP response
134
+ # @param defaults [Hash] Default values for missing headers
135
+ # @return [Hash] Parsed header values
136
+ def self.parse_stream_headers(response, defaults = {})
137
+ {
138
+ next_offset: response[STREAM_NEXT_OFFSET_HEADER] || defaults[:next_offset],
139
+ cursor: response[STREAM_CURSOR_HEADER] || defaults[:cursor],
140
+ up_to_date: response[STREAM_UP_TO_DATE_HEADER] == "true"
141
+ }
142
+ end
143
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableStreams
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require_relative "durable_streams/version"
5
+ require_relative "durable_streams/errors"
6
+ require_relative "durable_streams/types"
7
+ require_relative "durable_streams/http/transport"
8
+ require_relative "durable_streams/configuration"
9
+ require_relative "durable_streams/context"
10
+ require_relative "durable_streams/client"
11
+ require_relative "durable_streams/stream"
12
+ require_relative "durable_streams/json_reader"
13
+ require_relative "durable_streams/byte_reader"
14
+ require_relative "durable_streams/sse_reader"
15
+ require_relative "durable_streams/producer"
16
+
17
+ module DurableStreams
18
+ class << self
19
+ attr_accessor :logger
20
+
21
+ # Get the global configuration
22
+ # @return [Configuration]
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ # Configure DurableStreams with a block.
28
+ # The configuration is deep-frozen after the block completes.
29
+ #
30
+ # @example
31
+ # DurableStreams.configure do |config|
32
+ # config.base_url = ENV["DURABLE_STREAMS_URL"]
33
+ # config.default_content_type = :json
34
+ # config.default_headers = { "Authorization" => -> { "Bearer #{token}" } }
35
+ # end
36
+ #
37
+ # @yield [Configuration] The configuration object to modify
38
+ def configure
39
+ new_config = configuration.dup
40
+ yield(new_config)
41
+ @configuration = deep_freeze(new_config)
42
+ @default_context = nil # Reset cached context on config change
43
+ end
44
+
45
+ # Reset configuration to defaults (mainly for testing)
46
+ def reset_configuration!
47
+ @configuration = Configuration.new
48
+ @default_context = nil
49
+ end
50
+
51
+ # Create a new context with optional customizations.
52
+ # Use for isolated configurations (e.g., staging vs production).
53
+ #
54
+ # @example
55
+ # staging = DurableStreams.new_context do |config|
56
+ # config.base_url = "https://staging.example.com"
57
+ # end
58
+ #
59
+ # @yield [Configuration] Optional block to customize the context
60
+ # @return [Context]
61
+ def new_context(&block)
62
+ Context.new(configuration, &block)
63
+ end
64
+
65
+ # Get the default context (uses global configuration)
66
+ # @return [Context]
67
+ def default_context
68
+ @default_context ||= new_context
69
+ end
70
+
71
+ # Get a Stream handle for the given URL
72
+ # @param url [String] Full URL or path (if base_url configured)
73
+ # @param context [Context] Optional context (defaults to global)
74
+ # @return [Stream]
75
+ def stream(url, context: default_context)
76
+ Stream.new(url, context: context)
77
+ end
78
+
79
+ # Create a new stream on the server
80
+ # @param url [String] Stream URL or path
81
+ # @param content_type [Symbol, String] Content type (:json, :bytes, or MIME type)
82
+ # @param context [Context] Optional context
83
+ # @return [Stream]
84
+ def create(url, content_type:, context: default_context, **options)
85
+ Stream.create(url, content_type: content_type, context: context, **options)
86
+ end
87
+
88
+ # One-shot append to a stream
89
+ # @param url [String] Stream URL or path
90
+ # @param data [Object] Data to append
91
+ # @param context [Context] Optional context
92
+ # @return [AppendResult]
93
+ def append(url, data, context: default_context, **options)
94
+ stream(url, context: context).append(data, **options)
95
+ end
96
+
97
+ # Read from a stream
98
+ # @param url [String] Stream URL or path
99
+ # @param offset [String] Starting offset (default: "-1" for beginning)
100
+ # @param live [Boolean, Symbol] Live mode (false, :long_poll, :sse)
101
+ # @param format [Symbol] Format hint (:auto, :json, :bytes)
102
+ # @param context [Context] Optional context
103
+ # @return [JsonReader, ByteReader] Reader for iterating messages
104
+ def read(url, offset: "-1", live: false, format: :auto, context: default_context, **options)
105
+ stream(url, context: context).read(offset: offset, live: live, format: format, **options)
106
+ end
107
+
108
+ private
109
+
110
+ # Deep freeze an object and all nested mutable objects
111
+ def deep_freeze(obj)
112
+ case obj
113
+ when Hash
114
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
115
+ when Array
116
+ obj.each { |v| deep_freeze(v) }
117
+ end
118
+ obj.freeze
119
+ end
120
+ end
121
+
122
+ # Default logger - outputs warnings and errors to stderr
123
+ # Set to nil to disable, or replace with your own logger
124
+ self.logger = Logger.new($stderr, level: Logger::WARN)
125
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: durable_streams
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tokimonki
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: net-http-persistent
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.6'
54
+ description: A maintained Ruby client for the Durable Streams protocol — persistent,
55
+ resumable event streams over HTTP. Based on the upstream reference client with manual
56
+ review, testing, and ongoing maintenance.
57
+ email:
58
+ - opensource@tokimonki.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - UPSTREAM.md
67
+ - lib/durable_streams.rb
68
+ - lib/durable_streams/byte_reader.rb
69
+ - lib/durable_streams/client.rb
70
+ - lib/durable_streams/configuration.rb
71
+ - lib/durable_streams/context.rb
72
+ - lib/durable_streams/errors.rb
73
+ - lib/durable_streams/http/transport.rb
74
+ - lib/durable_streams/json_reader.rb
75
+ - lib/durable_streams/producer.rb
76
+ - lib/durable_streams/sse_reader.rb
77
+ - lib/durable_streams/stream.rb
78
+ - lib/durable_streams/testing.rb
79
+ - lib/durable_streams/types.rb
80
+ - lib/durable_streams/version.rb
81
+ homepage: https://github.com/tokimonki/durable_streams
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ source_code_uri: https://github.com/tokimonki/durable_streams
86
+ changelog_uri: https://github.com/tokimonki/durable_streams/blob/main/CHANGELOG.md
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.1.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.6.9
103
+ specification_version: 4
104
+ summary: Ruby client for Durable Streams protocol
105
+ test_files: []