openui-rails 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d3b9295fd6e8fb6d3b284c0c66d80e3d02f01e7d1c09913f02acfd6ff183273
4
- data.tar.gz: 9e5eb58014af64320ea178b05f63843f617f5fe3fa650e083f411a116a12dff2
3
+ metadata.gz: 1fd5862a42a9eaf7c669e6e94fa38d8aaac1784e4f7efe5340e9cec48756902a
4
+ data.tar.gz: f7ada4e9693c3de0c0d13b05a7cc7692a44b402b291721d430038a68c6047f4c
5
5
  SHA512:
6
- metadata.gz: bcbbc65b5a3a3d66839d727e325238643973050b39248695e6623b4f45c7fd343ecea7cf52c8c59f70fe6caf63aace284c677d4cd50211ed4fef73e9fc984da7
7
- data.tar.gz: 53147d0d353adf39e448f3ea82ac1c60dd1f7fee9168573d5802b052a3bd54fbd949fd9ac5b8d591ff134cc627c9204feeb07ffd1289304109b5eedd41ea08c0
6
+ metadata.gz: 0dc08e62c9508a13e11fa3d4995717d6b88be85458d505da3c363bccbfa1b57a1017bc59d72d667ed3d8043eb68f1005cd18b77bc87bae3ac9df0d2fe4683017
7
+ data.tar.gz: 524eb2239e400a15e6e0dee14d98b5d71e0a6f94866e4bfafda48917ea17f55a18a9eda9126cb13d7a7e4e149bd3ecfaa4f755ebac32f4ba2ca99d9b18db1d01
data/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - Optional `Actions::Idempotency::ActiveRecordStore` with atomic database claims,
14
+ ownership fencing, JSON result persistence, and explicit expiry cleanup.
15
+ SQLite is the verified database; memory remains the default.
16
+ - `openui:idempotency` generates the opt-in table and its unique key and expiry
17
+ indexes. Installing the engine alone creates no shared-store table.
18
+ - Reusable Minitest store-conformance checks, independent-process SQLite tests,
19
+ and a fresh-host migration and restart check against the built gem.
20
+
21
+ ### Changed
22
+
23
+ - The support-desk reference uses the shared store while retaining its business
24
+ uniqueness constraint. A shared claim does not guarantee exactly-once effects.
25
+
9
26
  ## [0.3.0] - 2026-10-19
10
27
 
11
28
  ### Added
@@ -116,7 +133,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
116
133
  - Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
117
134
  release environment and verified artifact handoff.
118
135
 
119
- [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.3.0...main
136
+ [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.4.0...main
137
+ [0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
120
138
  [0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
121
139
  [0.2.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.2.0
122
140
  [0.1.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.1.0
data/CONTRIBUTING.md CHANGED
@@ -25,7 +25,7 @@ bundle exec bin/rails test # unit + integration tests
25
25
  bundle exec bin/rails test:system # browser (system) tests, needs Chrome
26
26
  bundle exec rubocop # style (rubocop-rails-omakase)
27
27
  bundle exec gem build openui-rails.gemspec
28
- bundle exec ruby bin/smoke-package openui-rails-0.3.0.gem
28
+ bundle exec ruby bin/smoke-package openui-rails-0.4.0.gem
29
29
  bundle exec bundler-audit check --update
30
30
  bundle exec ruby -Itest test/compatibility/ruby_llm_smoke.rb
31
31
  ```
data/DEVELOPMENT.md CHANGED
@@ -17,7 +17,7 @@ authorizes a live call. Follow this loop for each feature:
17
17
  ```sh
18
18
  bin/check-reference
19
19
  bin/check-reference --runtime tmp/real-runtime
20
- bin/check-reference --gem tmp/package/openui-rails-0.3.0.gem --runtime tmp/real-runtime
20
+ bin/check-reference --gem tmp/package/openui-rails-0.4.0.gem --runtime tmp/real-runtime
21
21
  # Focus one browser scenario:
22
22
  bin/check-reference --runtime tmp/real-runtime --name test_a_lost_turbo_envelope_recovers_from_the_scoped_snapshot
23
23
  ```
data/README.md CHANGED
@@ -22,8 +22,8 @@ or renderer.
22
22
  - One allowlisted action endpoint with authorization, validation, and replay protection.
23
23
  - An optional RubyLLM streaming adapter.
24
24
 
25
- Your app supplies scoped record lookup, stream subscriptions, snapshot recovery,
26
- and shared idempotency storage when running multiple processes.
25
+ Your app supplies scoped record lookup, stream subscriptions, and snapshot
26
+ recovery. Multi-process apps can opt into the SQLite shared idempotency store.
27
27
 
28
28
  ## Get started
29
29
 
@@ -32,7 +32,7 @@ The runtime-fetch task also needs npm. RubyLLM is optional.
32
32
 
33
33
  ```ruby
34
34
  # Gemfile
35
- gem "openui-rails", "~> 0.3.0"
35
+ gem "openui-rails", "~> 0.4.0"
36
36
  ```
37
37
 
38
38
  ```sh
@@ -65,15 +65,15 @@ The documentation site contains the full guides and API reference:
65
65
  - [RubyLLM](https://openui-rails.wangyaodi.com/ruby-llm/) and [production setup](https://openui-rails.wangyaodi.com/production/)
66
66
  - [Upgrading](https://openui-rails.wangyaodi.com/upgrading/) and [API reference](https://openui-rails.wangyaodi.com/api-reference/)
67
67
 
68
- Version `0.3.0` adds a persisted reference workflow and stream restoration.
69
- See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-3/) for the upgrade.
68
+ Version `0.4.0` adds optional shared replay storage for Rails workers.
69
+ See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-4/) for the upgrade.
70
70
  The API may change during `0.x`; see the
71
71
  [changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
72
72
  The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
73
73
  follows `main`.
74
74
 
75
75
  For repository work, use [Contributing](CONTRIBUTING.md),
76
- [Releasing](RELEASING.md), and the [0.2.0 release record](RELEASE_0_2.md).
76
+ [Releasing](RELEASING.md), and the [0.3.0 release record](RELEASE_0_3.md).
77
77
  Report vulnerabilities through the [security policy](SECURITY.md).
78
78
 
79
79
  ## License
@@ -2,14 +2,16 @@
2
2
 
3
3
  This opt-in reference belongs to the host application, outside the engine's
4
4
  public API. It uses synthetic accounts, recorded generation, the real pinned
5
- OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams.
5
+ OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams. It opts into the
6
+ Active Record idempotency store and retains a unique business receipt for closing
7
+ a ticket. Shared claims do not replace that database constraint.
6
8
 
7
9
  From the repository:
8
10
 
9
11
  ```sh
10
12
  mkdir -p tmp/package
11
- bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.3.0.gem
12
- bin/check-reference --gem tmp/package/openui-rails-0.3.0.gem --runtime tmp/real-runtime
13
+ bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.4.0.gem
14
+ bin/check-reference --gem tmp/package/openui-rails-0.4.0.gem --runtime tmp/real-runtime
13
15
  ```
14
16
 
15
17
  Fetch the runtime first. See the [complete guide](https://openui-rails.wangyaodi.com/reference-workflow/)
@@ -8,6 +8,7 @@ OpenUI::Rails.configure do |config|
8
8
  config.surface_resolver = ->(id, actor:, **) {
9
9
  SupportDesk::Conversation.find_by(id: id, user_id: actor.id, tenant_id: actor.tenant_id) if actor
10
10
  }
11
+ config.idempotency_store = OpenUI::Rails::Actions::Idempotency::ActiveRecordStore.new
11
12
  config.idempotency_scope = ->(invocation) { [ invocation.actor.tenant_id, invocation.actor.id ] }
12
13
  config.authorize_action = ->(invocation) {
13
14
  actor, conversation = invocation.actor, invocation.surface
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module OpenUI
6
+ # Generate the opt-in shared action-claim table without changing configuration.
7
+ class IdempotencyGenerator < ::ActiveRecord::Generators::Base
8
+ namespace "openui:idempotency"
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Create the optional OpenUI shared idempotency store migration."
11
+ # This generator has no model-name argument.
12
+ argument :name, type: :string, default: "openui_action_claims", hide: true
13
+
14
+ # Write the optional, reversible host migration.
15
+ def create_store_migration
16
+ migration_template "create_openui_action_claims.rb", "db/migrate/create_openui_action_claims.rb"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOpenUIActionClaims < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :openui_action_claims do |t|
6
+ t.string :key, null: false
7
+ t.string :digest
8
+ t.string :claim_token, null: false
9
+ t.boolean :completed, null: false, default: false
10
+ t.text :result_json
11
+ t.datetime :expires_at, null: false, precision: 6
12
+ end
13
+ add_index :openui_action_claims, :key, unique: true
14
+ add_index :openui_action_claims, :expires_at
15
+ end
16
+ end
@@ -14,6 +14,10 @@ OpenUI::Rails.configure do |config|
14
14
  config.runtime_assets = :self_hosted
15
15
  # config.runtime_integrity = "sha384-..."
16
16
 
17
+ # Optional shared SQLite claims: first run openui:idempotency and db:migrate.
18
+ # See the Shared Idempotency Storage guide for JSON results and crash limits.
19
+ # config.idempotency_store = OpenUI::Rails::Actions::Idempotency::ActiveRecordStore.new
20
+
17
21
  # Authorize every action/tool call. This fails closed: with no authorizer an
18
22
  # action is denied. Return truthy to permit.
19
23
  # config.authorize_action = ->(invocation) do
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module OpenUI
8
+ module Rails
9
+ module Actions
10
+ module Idempotency
11
+ # Optional shared action store. Verified with SQLite on Rails 8.0/8.1.
12
+ # Install its table with `rails generate openui:idempotency` and db:migrate.
13
+ # JSON values replay across workers; object keys become strings. TTL runs
14
+ # from claim creation, not completion. Never assumes exactly-once effects.
15
+ class ActiveRecordStore
16
+ class Entry < ::ActiveRecord::Base # :nodoc:
17
+ self.table_name = "openui_action_claims"
18
+ end
19
+ private_constant :Entry
20
+
21
+ # @param model [Class] dedicated Active Record model, optionally using another database
22
+ # @param clock [#call] shared wall clock returning UTC Time values
23
+ def initialize(model: Entry, clock: -> { Time.now.utc })
24
+ @model = model
25
+ @clock = clock
26
+ end
27
+
28
+ # Atomically insert or replace an expired claim. Returns the same
29
+ # [status, result, stored_digest, token] contract as MemoryStore.
30
+ # A changed digest is returned to the dispatcher, which rejects it.
31
+ def claim(key, ttl:, digest: nil)
32
+ raise ConfigurationError, "idempotency ttl must be positive" unless ttl.is_a?(Numeric) && ttl.finite? && ttl > 0
33
+
34
+ loop do
35
+ now = @clock.call
36
+ attributes = { key: key, digest: digest, claim_token: SecureRandom.hex(16),
37
+ completed: false, result_json: nil, expires_at: now + ttl }
38
+ inserted = @model.insert_all([ attributes ], unique_by: :key, returning: %w[id])
39
+ return [ :claimed, nil, digest, attributes[:claim_token] ] if inserted.any?
40
+
41
+ replaced = @model.where(key: key).where("expires_at <= ?", now).update_all(attributes.except(:key))
42
+ return [ :claimed, nil, digest, attributes[:claim_token] ] if replaced == 1
43
+
44
+ # Cleanup can delete the expired row between SQL statements.
45
+ entry = @model.find_by(key: key)
46
+ next unless entry
47
+
48
+ value = entry.completed ? JSON.parse(entry.result_json) : nil
49
+ return [ entry.completed ? :replay : :pending, value, entry.digest, entry.claim_token ]
50
+ end
51
+ end
52
+
53
+ # Record only while this token owns a live claim. JSON encoding rejects
54
+ # unsupported Ruby objects before writing; it never invokes Marshal.
55
+ def record(key, result, claim_token:)
56
+ json = JSON.generate(JsonValue.copy(result))
57
+ @model.where(key: key, claim_token: claim_token).where("expires_at > ?", @clock.call)
58
+ .update_all(result_json: json, completed: true)
59
+ nil
60
+ rescue ArgumentError, JSON::GeneratorError => error
61
+ raise ConfigurationError, "idempotency result must be a JSON value: #{error.message}"
62
+ end
63
+
64
+ # Delete expired claims, returning the number removed. Call from a host
65
+ # maintenance task; expiry of the requested key never needs this sweep.
66
+ def delete_expired
67
+ @model.where("expires_at <= ?", @clock.call).delete_all
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -7,8 +7,8 @@ module OpenUI
7
7
  module Actions
8
8
  module Idempotency
9
9
  # Default, single-process idempotency store. Hosts that run multiple
10
- # processes should inject a shared store (e.g. Redis or the database)
11
- # that implements the same contract.
10
+ # processes can opt into ActiveRecordStore on SQLite
11
+ # or inject another shared adapter implementing the same contract.
12
12
  #
13
13
  # Contract:
14
14
  # claim(key, ttl:, digest:) -> [status, result, digest, claim_token]
@@ -13,6 +13,9 @@ module OpenUI
13
13
  # a host authorizer, stale-surface protection and idempotency, shared by
14
14
  # both component actions and browser tool calls.
15
15
  module Actions
16
+ module Idempotency
17
+ autoload :ActiveRecordStore, "openui/rails/actions/idempotency/active_record_store"
18
+ end
16
19
  end
17
20
  end
18
21
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Testing
6
+ # Reusable Minitest contract for idempotency adapters. Include this module
7
+ # in a test case that defines build_store and advance_store_clock(seconds).
8
+ # Each test needs an empty store and a controllable shared clock.
9
+ module IdempotencyStoreContract
10
+ def test_claim_distinguishes_pending_from_completed
11
+ store = build_store
12
+ status, value, digest, token = store.claim("key", ttl: 60, digest: "original")
13
+ assert_equal :claimed, status
14
+ assert_nil value
15
+ assert_equal "original", digest
16
+ refute_empty token
17
+ assert_equal [ :pending, nil, "original", token ], store.claim("key", ttl: 60, digest: "changed")
18
+ store.record("key", "saved", claim_token: token)
19
+ assert_equal [ :replay, "saved", "original", token ], store.claim("key", ttl: 60, digest: "changed")
20
+ end
21
+
22
+ def test_completed_nil_and_false_replay
23
+ store = build_store
24
+ [ nil, false ].each_with_index do |value, index|
25
+ key = "nullable-#{index}"
26
+ token = store.claim(key, ttl: 60, digest: "d").last
27
+ store.record(key, value, claim_token: token)
28
+ assert_equal [ :replay, value, "d", token ], store.claim(key, ttl: 60, digest: "d")
29
+ end
30
+ end
31
+
32
+ def test_json_results_round_trip
33
+ store = build_store
34
+ value = { "list" => [ 1, 2.5, true, false, nil, "你好" ], "nested" => { "ok" => true } }
35
+ token = store.claim("key", ttl: 60, digest: "d").last
36
+ store.record("key", value, claim_token: token)
37
+ assert_equal value, store.claim("key", ttl: 60, digest: "d")[1]
38
+ end
39
+
40
+ def test_expired_owner_cannot_record
41
+ store = build_store
42
+ token = store.claim("key", ttl: 10, digest: "old").last
43
+ advance_store_clock(10)
44
+ store.record("key", "late", claim_token: token)
45
+ status, value, _, replacement = store.claim("key", ttl: 60, digest: "new")
46
+ assert_equal :claimed, status
47
+ assert_nil value
48
+ refute_equal token, replacement
49
+ end
50
+
51
+ def test_replaced_owner_cannot_overwrite
52
+ store = build_store
53
+ old = store.claim("key", ttl: 10, digest: "old").last
54
+ advance_store_clock(11)
55
+ token = store.claim("key", ttl: 60, digest: "new").last
56
+ store.record("key", "new result", claim_token: token)
57
+ store.record("key", "old result", claim_token: old)
58
+ assert_equal [ :replay, "new result", "new", token ], store.claim("key", ttl: 60, digest: "new")
59
+ end
60
+
61
+ def test_wrong_token_and_unknown_key_do_not_complete_a_claim
62
+ store = build_store
63
+ token = store.claim("key", ttl: 60, digest: "d").last
64
+ store.record("missing", "value", claim_token: token)
65
+ store.record("key", "value", claim_token: "wrong")
66
+ assert_equal :pending, store.claim("key", ttl: 60, digest: "d").first
67
+ assert_equal :claimed, store.claim("missing", ttl: 60, digest: "d").first
68
+ end
69
+
70
+ def test_ttl_must_be_positive_and_finite
71
+ store = build_store
72
+ [ 0, -1, Float::INFINITY, Float::NAN, "60", nil ].each do |ttl|
73
+ assert_raises(OpenUI::Rails::ConfigurationError) { store.claim("key", ttl: ttl, digest: "d") }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  module OpenUI
2
2
  module Rails
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Wang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -80,12 +80,15 @@ files:
80
80
  - examples/support_desk/config/routes.rb
81
81
  - examples/support_desk/db/migrate/20261019000000_create_support_desk.rb
82
82
  - examples/support_desk/install.rb
83
+ - lib/generators/openui/idempotency/idempotency_generator.rb
84
+ - lib/generators/openui/idempotency/templates/create_openui_action_claims.rb
83
85
  - lib/generators/openui/install/install_generator.rb
84
86
  - lib/generators/openui/install/templates/config/initializers/openui.rb.tt
85
87
  - lib/generators/openui/install/templates/config/openui/README.md
86
88
  - lib/openui/rails.rb
87
89
  - lib/openui/rails/actions.rb
88
90
  - lib/openui/rails/actions/dispatcher.rb
91
+ - lib/openui/rails/actions/idempotency/active_record_store.rb
89
92
  - lib/openui/rails/actions/idempotency/memory_store.rb
90
93
  - lib/openui/rails/actions/invocation.rb
91
94
  - lib/openui/rails/actions/registry.rb
@@ -116,6 +119,7 @@ files:
116
119
  - lib/openui/rails/stream/transports/memory.rb
117
120
  - lib/openui/rails/stream/transports/turbo_streams.rb
118
121
  - lib/openui/rails/surface.rb
122
+ - lib/openui/rails/testing/idempotency_store_contract.rb
119
123
  - lib/openui/rails/version.rb
120
124
  - lib/openui/rails/view_helper.rb
121
125
  - lib/tasks/openui.rake