citrine-remote 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: acc440381ae676270dea4fb9c762015773ad20dc4e62c84b456ca548ebc46edd
4
+ data.tar.gz: 63c2f4ea3b6eacbb4ca2ec9e02e95d949f20450a01b501e06f581abb19a632e4
5
+ SHA512:
6
+ metadata.gz: 0ae758da6eac4c119a5e646b17e91c25f50953454ec71db41479be8fe2e0a2c8ff70d59e6637f4c550aa5637a8a5d2f162e8cb456e3662df5be1c7b084f163b4
7
+ data.tar.gz: ac024f18565080b922c3655f023aa2cc251ec23892b7c7545d1097b1e0e3cf2a18d88529e876b120ea8242d41ebca8cae976ee7d6e9fda5ec64ebbe5cd5ac02b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shiningray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Citrine::Remote — 服务器权威状态的前端镜像原语
2
+
3
+ 从 citrine-remote spike 沉淀的可复用组件。核心主张:**视图只是信号的投影**——
4
+ 只要把服务器状态以 Signal 形态镜像到前端,整个 Citrine 视图层(Effect/Component/
5
+ 渲染器)零修改获得实时同步。前端状态 = 服务器共享状态的镜像 + 本地瞬时 UI 状态。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ gem install citrine-remote # 依赖 citrine (>= 0.2.0),自动带入
11
+ ```
12
+
13
+ 或 Gemfile:`gem "citrine-remote"`。仓库内的 examples/ 需克隆本仓库后按下文运行
14
+ (示例用工作区内的 citrine 源码,不消费 rubygems 版本)。
15
+
16
+ 目录布局对齐 gem 惯例,未来可原样迁入 `citrine/lib` 或独立成 gem。
17
+
18
+ ## 架构
19
+
20
+ ```
21
+ 权威侧(CRuby 服务器) 镜像侧(CRuby / Opal 浏览器)
22
+ Citrine::Signal(普通信号) Citrine::Remote::Session(注册表+分发)
23
+ ↓ Effect 广播 ↓ apply_remote(广播但不回传,防回声)
24
+ Citrine::Remote::Hub Citrine::Remote::Signal
25
+ │ patch 流 │ set = 乐观生效 + propose
26
+ └────── Wire(传输适配器,双向) ─────────┘
27
+ ```
28
+
29
+ Wire 契约(回调式鸭子类型,两侧一致):
30
+
31
+ | 方法 | 语义 |
32
+ |---|---|
33
+ | `send_msg(hash)` | 发送一条消息 |
34
+ | `on_msg { |hash| }` | 收包回调(字符串键,Session 负责归一化) |
35
+ | `on_close { }` | 断线回调 |
36
+
37
+ 消息协议 v0(SPEC-endpoint-protocol 信封的退化形):
38
+ `propose`(client→server,写入提案)与 `patch`(server→client,权威广播)。
39
+
40
+ ## API
41
+
42
+ ```ruby
43
+ require "citrine/remote" # 平台无关四件(Opal 可编译)
44
+ require "citrine/remote/jsonl_wire" # CRuby TCP 适配器(按需)
45
+ require "citrine/remote/ws_wire" # CRuby WS 服务器适配器(按需,spike 级手写)
46
+ require "citrine/remote/browser_ws_wire" # Opal 浏览器原生 WS 适配器(按需)
47
+
48
+ # 权威侧:发布信号。Effect 首跑即快照,之后每次 set 自动增量下发。
49
+ hub = Citrine::Remote::Hub.new(wire)
50
+ hub.publish("win:1:geom", geom_signal)
51
+ hub.watch("wm:summary") { list_signal.get.map { ... } } # 派生值:块内读到的信号即依赖
52
+ hub.unpublish("win:1:geom")
53
+ hub.dispose
54
+
55
+ # 镜像侧:注册表跨连接存活(WireBox 换线),视图只读信号。
56
+ box = Citrine::Remote::WireBox.new
57
+ session = Citrine::Remote::Session.new(box)
58
+ session.on_patch = ->(key) { ... } # 可选:来源标注(先于渲染触发)
59
+ box.wire = transport # 任一 Wire 契约实现
60
+ geom = session.sig("win:1:geom", 初始值) # 同一 key 恒为同一实例
61
+ geom.get # 视图订阅,本地命中零延迟
62
+ geom.set(v) # 乐观生效 + 提案(服务器可回滚)
63
+ ```
64
+
65
+ ## 运行
66
+
67
+ ```bash
68
+ # 本目录即 .ruby-version(3.3.5)
69
+ rake # 16 项测试(纯 CRuby + 内存 wire)
70
+
71
+ ruby examples/window_server.rb # 终端 1:demo1 权威侧
72
+ ruby examples/window_client.rb # 终端 2:demo1 镜像侧(单窗口,拖拽提案/回滚)
73
+
74
+ ruby examples/desktop_server.rb # 终端 1:demo2 桌面权威侧(多窗口+断线重连)
75
+ ruby examples/desktop_client.rb # 终端 2:demo2 镜像侧(ASCII 渲染)
76
+
77
+ ruby examples/browser/ws_server.rb # 浏览器版服务器(无限循环模式)
78
+ open http://127.0.0.1:4470/ # 真 DOM 桌面投影
79
+ ```
80
+
81
+ 浏览器端改动 `examples/browser/app.rb` 后重新编译:
82
+
83
+ ```bash
84
+ cd ../citrine && bundle exec opal -c -Ilib -I../citrine-remote/lib -I../citrine-remote/examples/browser \
85
+ -o ../citrine-remote/examples/browser/app.js ../citrine-remote/examples/browser/app.rb
86
+ ```
87
+
88
+ ## 设计要点(spike 结论)
89
+
90
+ - **Signal 是远程同步的正确粒度**:视图对远程零感知,ASCII 与 DOM 两个渲染器同验。
91
+ - **防回声在原语内**:`apply_remote`(生效+广播+不回传)一个方法终结回声循环。
92
+ - **幂等收敛白捡**:相等短路使重连快照零冗余渲染;`Effect` 首跑即快照。
93
+ - **权威侧零新原语**:普通 Signal + Effect 即广播器;列表 Effect reconcile 出
94
+ 每窗口的 publish 生命周期(见 `world.rb` 的 `DesktopMirror`)。
95
+ - **平台无关纪律**:`remote.rb` 入口禁止 Thread/Mutex/IO/Native——这是 Opal 编译
96
+ 一次通过的原因。阻塞循环只能待在 CRuby 适配器内部。
97
+
98
+ ## 已知限制与下一步
99
+
100
+ - [ ] `Citrine::Effect.stack` 跨线程共享——CRuby 多连接服务器需内核线程本地化
101
+ - [ ] Wire 回调槽独占制——正式契约应多监听者或显式独占标注
102
+ - [ ] 断线窗口期提案丢失——正式协议需提案排队 + ack
103
+ - [ ] 部分状态间隙(列表 patch 先于几何 patch)——视图须容忍,或引入复合消息/barrier
104
+ - [ ] 消息信封升格 SPEC-endpoint-protocol(message_id / ask-result / priority)
@@ -0,0 +1,20 @@
1
+ # backtick_javascript: true
2
+ require "native"
3
+
4
+ module Citrine
5
+ module Remote
6
+ # Opal 传输适配器:浏览器原生 WebSocket。仅 Opal 环境加载。
7
+ # 规则:绝不在 Opal 里手写 WS 协议——浏览器给什么用什么。
8
+ class BrowserWsWire
9
+ def initialize(url)
10
+ @ws = Native(`new WebSocket(#{url})`)
11
+ @ws.addEventListener("message", ->(ev) { @on_msg&.call(JSON.parse(Native(ev)[:data])) })
12
+ @ws.addEventListener("close", ->(*) { @on_close&.call })
13
+ end
14
+
15
+ def send_msg(h) = @ws.send(JSON.generate(h))
16
+ def on_msg(&b) = @on_msg = b
17
+ def on_close(&b) = @on_close = b
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,44 @@
1
+ module Citrine
2
+ module Remote
3
+ # 权威侧广播器:把本地信号发布为远程可镜像的 patch 流。
4
+ # 权威侧不需要新原语——普通 Citrine::Signal + Effect 即广播器:
5
+ # Effect 创建即首跑,所以连接建立(Hub 创建或 publish 调用)立刻下发快照;
6
+ # 之后每次 set 自动作为增量 patch 下发;unpublish/dispose 停止。
7
+ class Hub
8
+ def initialize(wire)
9
+ @wire = wire
10
+ @pubs = {}
11
+ end
12
+
13
+ # 发布一个信号:每次变更广播一条 patch,首跑即发当前值(快照)
14
+ def publish(key, signal)
15
+ watch(key) { signal.get }
16
+ end
17
+
18
+ # 发布一个派生值:块内读到的信号即依赖(如列表摘要、计算属性)
19
+ def watch(key, &block)
20
+ unpublish(key)
21
+ @pubs[key] = Citrine::Effect.create { emit_patch(key, block.call) }
22
+ self
23
+ end
24
+
25
+ def unpublish(key)
26
+ @pubs.delete(key)&.dispose
27
+ self
28
+ end
29
+
30
+ def dispose
31
+ @pubs.keys.each { |k| unpublish(k) }
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def emit_patch(key, value)
38
+ @wire.send_msg("kind" => "patch", "key" => key, "value" => value)
39
+ rescue StandardError # 写已断开的连接:自愈(Opal 侧 JS 异常同口径)
40
+ dispose
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ module Citrine
2
+ module Remote
3
+ # CRuby 传输适配器:JSON Lines over TCPSocket。
4
+ # 阻塞读循环藏在 #start 的线程里,对外只暴露回调契约。仅 CRuby 加载。
5
+ class JsonlWire
6
+ def initialize(io)
7
+ @io = io
8
+ @wlock = Mutex.new
9
+ end
10
+
11
+ def on_msg(&b) = @on_msg = b
12
+ def on_close(&b) = @on_close = b
13
+
14
+ def send_msg(h)
15
+ @wlock.synchronize do
16
+ @io.write(JSON.generate(h) + "\n")
17
+ @io.flush
18
+ end
19
+ end
20
+
21
+ def start
22
+ Thread.new do
23
+ begin
24
+ @io.each_line { |l| @on_msg&.call(JSON.parse(l)) }
25
+ rescue SystemCallError, IOError, EOFError, JSON::ParserError
26
+ end
27
+ @on_close&.call
28
+ end
29
+ self
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ module Citrine
2
+ module Remote
3
+ # 客户端会话:消息分发 + 信号注册表(key → Signal)。
4
+ # 视图层只读 #sig 拿到的信号,对"远程"零感知。
5
+ class Session
6
+ # on_patch:每条 patch 应用**之前**回调(广播是同步渲染,标注来源必须先置位)
7
+ attr_accessor :on_patch
8
+
9
+ def initialize(box)
10
+ @box = box
11
+ @registry = {}
12
+ box.on_msg { |m| dispatch(m) }
13
+ end
14
+
15
+ # 同一 key 始终返回同一个信号实例(注册表即身份映射)
16
+ def sig(key, init)
17
+ @registry[key] ||= Signal.new(key, init, wire: @box)
18
+ end
19
+
20
+ def dispatch(msg)
21
+ return unless msg["kind"] == "patch"
22
+
23
+ @on_patch&.call(msg["key"])
24
+ sig(msg["key"], nil).apply_remote(deep_sym(msg["value"]))
25
+ end
26
+
27
+ private
28
+
29
+ # 消息一律字符串键(两侧 JSON 解析口径不同),值统一转符号
30
+ def deep_sym(v)
31
+ case v
32
+ when Hash then v.each_with_object({}) { |(k, val), h| h[k.to_sym] = deep_sym(val) }
33
+ when Array then v.map { |x| deep_sym(x) }
34
+ else v
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ module Citrine
2
+ module Remote
3
+ # 远程镜像信号:一个普通的 Citrine::Signal,写入路径分两条腿——
4
+ #
5
+ # #set(v) 本地写入 = 乐观生效 + 向服务器提交提案(propose)。
6
+ # 服务器是权威,有权修正:修正经 patch 回滚本地值。
7
+ # #apply_remote(v) 服务器下行:生效并广播(视图重渲染),但不回传。
8
+ # 防回声的关键操作——回声循环终结在原语里,与应用无关。
9
+ #
10
+ # 相等短路继承自 Citrine::Signal:重连快照里值未变的 patch 零渲染,
11
+ # 幂等收敛是底层语义白捡的,协议层无需设计。
12
+ class Signal < Citrine::Signal
13
+ attr_reader :key
14
+
15
+ def initialize(key, value, wire:)
16
+ super(value)
17
+ @key = key
18
+ @wire = wire
19
+ end
20
+
21
+ def set(v)
22
+ return self if v == peek
23
+
24
+ super
25
+ @wire.send_msg("kind" => "propose", "key" => @key, "value" => v)
26
+ self
27
+ end
28
+
29
+ def apply_remote(v)
30
+ return self if v == peek
31
+
32
+ @value = v
33
+ broadcast
34
+ self
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ module Citrine
2
+ module Remote
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ module Citrine
2
+ module Remote
3
+ # 可换底层连接的转发器:重连时替换 #wire,信号注册表跨连接存活。
4
+ # 回调注册在盒子上(盒子稳定、wire 是耗材),换线自动转挂。
5
+ # 注意:wire 的回调槽归本盒子独占——同一 wire 上不要再注册别的 on_msg/on_close。
6
+ # 断线期间的本地提案直接丢弃(真实系统应排队或拒绝——v0 从简)。
7
+ class WireBox
8
+ def initialize
9
+ @wire = nil
10
+ @on_msg = nil
11
+ @on_close = nil
12
+ end
13
+
14
+ def wire=(w)
15
+ @wire = w
16
+ w.on_msg { |m| @on_msg&.call(m) }
17
+ w.on_close { @on_close&.call }
18
+ end
19
+
20
+ def on_msg(&b) = @on_msg = b
21
+ def on_close(&b) = @on_close = b
22
+
23
+ def send_msg(h)
24
+ @wire&.send_msg(h)
25
+ rescue StandardError # 死线写入(IOError/SystemCallError;Opal 侧 JS 异常同口径):丢弃
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ require "digest/sha1"
2
+ require "base64"
3
+
4
+ module Citrine
5
+ module Remote
6
+ # CRuby 传输适配器:零依赖手写 WebSocket 服务器端(RFC6455 文本帧、
7
+ # 客户端帧带掩码)。spike 级实现——正式部署应换现成 gem 或 AgentOS 传输。
8
+ # 仅 CRuby 加载。用法:sock = TCPServer.accept 后 WsWire.new(sock) 完成握手。
9
+ class WsWire
10
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
11
+
12
+ def initialize(sock)
13
+ @sock = sock
14
+ @wlock = Mutex.new
15
+ sock.gets # 请求行
16
+ headers = {}
17
+ while (line = sock.gets) && line != "\r\n"
18
+ k, v = line.split(": ", 2)
19
+ headers[k.downcase] = v.strip
20
+ end
21
+ accept = Base64.strict_encode64(Digest::SHA1.digest(headers["sec-websocket-key"] + GUID))
22
+ sock.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" \
23
+ "Connection: Upgrade\r\nSec-WebSocket-Accept: #{accept}\r\n\r\n")
24
+ end
25
+
26
+ def on_msg(&b) = @on_msg = b
27
+ def on_close(&b) = @on_close = b
28
+
29
+ def send_msg(h)
30
+ payload = JSON.generate(h)
31
+ head = [0x81].pack("C")
32
+ len = payload.bytesize
33
+ head += if len < 126 then [len].pack("C")
34
+ elsif len < 65_536 then [126, len].pack("Cn")
35
+ else [127, len].pack("CQ>")
36
+ end
37
+ @wlock.synchronize { @sock.write(head + payload) }
38
+ end
39
+
40
+ def start
41
+ Thread.new do
42
+ begin
43
+ loop { read_frame }
44
+ rescue SystemCallError, IOError, EOFError, JSON::ParserError
45
+ end
46
+ @on_close&.call
47
+ end
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ def read_frame
54
+ b1, b2 = read_exact(2).unpack("CC")
55
+ opcode = b1 & 0x0F
56
+ len = b2 & 0x7F
57
+ len = read_exact(2).unpack1("n") if len == 126
58
+ len = read_exact(8).unpack1("Q>") if len == 127
59
+ mask = (b2 & 0x80).zero? ? nil : read_exact(4).unpack("C4")
60
+ payload = read_exact(len)
61
+ payload = payload.bytes.each_with_index.map { |b, i| b ^ mask[i % 4] }.pack("C*") if mask
62
+ case opcode
63
+ when 1 then @on_msg&.call(JSON.parse(payload))
64
+ when 8 then raise EOFError
65
+ when 9 then @wlock.synchronize { @sock.write([0x8A, payload.bytesize].pack("CC") + payload) }
66
+ end
67
+ end
68
+
69
+ def read_exact(n)
70
+ n.zero? ? "" : (@sock.read(n) or raise EOFError)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,23 @@
1
+ # Citrine::Remote — 服务器权威状态的前端镜像原语。
2
+ #
3
+ # 平台无关核心纪律:本入口加载的四个文件禁止出现 Thread/Mutex/IO/Native,
4
+ # CRuby 与 Opal 均可加载。传输适配器按需单独 require:
5
+ # CRuby: citrine/remote/jsonl_wire(TCP)· citrine/remote/ws_wire(WS 服务器)
6
+ # Opal: citrine/remote/browser_ws_wire(浏览器原生 WebSocket)
7
+ #
8
+ # Wire 契约(回调式鸭子类型,两侧一致):
9
+ # send_msg(hash) 发送一条消息
10
+ # on_msg { |hash| } 收包回调(消息一律字符串键,由 Session 归一化)
11
+ # on_close { } 断线回调
12
+ #
13
+ # 消息协议(v0,SPEC-endpoint-protocol 信封的退化形):
14
+ # client→server {"kind":"propose","key":...,"value":...} 写入提案
15
+ # server→client {"kind":"patch", "key":...,"value":...} 权威广播
16
+ require "json"
17
+ require "citrine/signal"
18
+
19
+ require "citrine/remote/version"
20
+ require "citrine/remote/signal"
21
+ require "citrine/remote/wire_box"
22
+ require "citrine/remote/session"
23
+ require "citrine/remote/hub"
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: citrine-remote
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ShiningRay
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: citrine
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description: Citrine::Remote:把服务器权威状态以 Signal 形态镜像到前端。视图层零修改获得实时同步——前端状态 = 服务器共享状态的镜像
56
+ + 本地瞬时 UI 状态。提供 Remote::Signal(乐观提案/权威回滚)、Hub(权威侧广播器)、Session(镜像侧分发)与可替换的传输适配器(CRuby
57
+ TCP/WebSocket、Opal 浏览器原生 WebSocket)。
58
+ email:
59
+ - shiningray@users.noreply.github.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - lib/citrine/remote.rb
67
+ - lib/citrine/remote/browser_ws_wire.rb
68
+ - lib/citrine/remote/hub.rb
69
+ - lib/citrine/remote/jsonl_wire.rb
70
+ - lib/citrine/remote/session.rb
71
+ - lib/citrine/remote/signal.rb
72
+ - lib/citrine/remote/version.rb
73
+ - lib/citrine/remote/wire_box.rb
74
+ - lib/citrine/remote/ws_wire.rb
75
+ homepage: https://github.com/ShiningRay/citrine-remote
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ source_code_uri: https://github.com/ShiningRay/citrine-remote
80
+ changelog_uri: https://github.com/ShiningRay/citrine-remote/blob/main/README.md
81
+ rubygems_mfa_required: 'true'
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.1.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.5.22
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: 'Server-authoritative state mirroring for Citrine: views stay plain Signal
101
+ projections'
102
+ test_files: []