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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +22 -0
- data/README.md +276 -0
- data/UPSTREAM.md +85 -0
- data/lib/durable_streams/byte_reader.rb +185 -0
- data/lib/durable_streams/client.rb +68 -0
- data/lib/durable_streams/configuration.rb +26 -0
- data/lib/durable_streams/context.rb +35 -0
- data/lib/durable_streams/errors.rb +167 -0
- data/lib/durable_streams/http/transport.rb +213 -0
- data/lib/durable_streams/json_reader.rb +211 -0
- data/lib/durable_streams/producer.rb +436 -0
- data/lib/durable_streams/sse_reader.rb +228 -0
- data/lib/durable_streams/stream.rb +445 -0
- data/lib/durable_streams/testing.rb +277 -0
- data/lib/durable_streams/types.rb +143 -0
- data/lib/durable_streams/version.rb +5 -0
- data/lib/durable_streams.rb +125 -0
- metadata +105 -0
|
@@ -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,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: []
|