ask-state-providers 0.2.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: 18cfafbe7783b55543313b0708ed885275c8bd954a63cf152251dbfb465fde49
4
+ data.tar.gz: 60c56b1e649b86c893e8af07b446c3a022afb4d4b56d0419d74465b402ca36be
5
+ SHA512:
6
+ metadata.gz: 38f53d279574b58eddf256975467c8bb30047060e5e994093f38ced15feb61c7e02e34c9023b7ec02a669e1b94142ca9a26cdd9d27938c5f5d51aee5d61e0715
7
+ data.tar.gz: be37ac572c9081ab10bf85f878a6285fd04b3522c731ae0bc28d472c855584e567d791b21953f6e013df1cbf5ebe3c48146ba91aded39c4aa08e2e2c80456f1e
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.0] — 2026-07-23
6
+
7
+ ### Changed
8
+
9
+ - **BREAKING:** Removed `SessionPersistence` wrapper class — no longer needed. Session accepts `State::Adapter` directly via `state:` keyword.
10
+ - **BREAKING:** `Session.load` now calls `adapter.get(id)` instead of `adapter.load(id)`. Any custom adapter must respond to `get`/`set`/`delete`.
11
+
12
+ ### Added
13
+
14
+ - **Shared contract test suite** — `AdapterContract` module with 26 tests that every backend must pass. Runs against SQLite, Redis (via fakeredis), Postgres (when `DATABASE_URL` is set), and MySQL (when `MYSQL_URL` is set).
15
+ - **Per-turn persistence** — Session now persists after every LLM turn, not just at the end of `run()`. Mid-session crashes no longer lose progress.
16
+ - **`Session.load` restores `@messages`** — Previously `session.messages` returned `nil` after loading. Now it's populated from the restored chat messages.
17
+
18
+ ### Fixed
19
+
20
+ - **README** — Updated `persistence:` example to `state:`.
21
+
22
+ ## [0.1.0] — 2026-07-23
23
+
24
+ ### Added
25
+
26
+ - **`Ask::State::Providers::SQLite`** — Persistent key-value store backed by SQLite with WAL mode. Supports all `Adapter` primitives: key-value (get/set/delete/set_if_not_exists/clear, TTL), distributed locking (acquire/release with token safety), message queues (FIFO enqueue/dequeue/depth), and ordered lists (append/range/remove with max-length trimming). 58 tests.
27
+
28
+ - **`Ask::State::Providers::Redis`** — Distributed state store backed by Redis with `ask:state:` key namespace. Uses `SET NX EX` for atomic locking, `RPUSH`/`LPOP` for queues, `LTRIM` for bounded lists, and Lua `EVAL` for safe lock release. 40 tests (tested with fakeredis).
29
+
30
+ - **`Ask::State::Providers::PostgreSQL`** — State store backed by PostgreSQL with connection pooling, `ON CONFLICT` handling, and `RETURNING` clauses. Tests skip gracefully when `DATABASE_URL` is not set.
31
+
32
+ - **`Ask::State::Providers::MySQL`** — State store backed by MySQL/MariaDB with prepared statements and `ON DUPLICATE KEY UPDATE`. Tests skip gracefully when `MYSQL_URL` is not set.
33
+
34
+ - **Autoloading** — All providers use Ruby `autoload` so only the backends you need are loaded.
35
+
36
+ - **CI** — GitHub Actions workflow testing Ruby 3.2, 3.3, and 3.4.
37
+
38
+ - **Documentation** — Full README with quick start, backend comparison table, and API reference.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
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,220 @@
1
+ # ask-state-providers
2
+
3
+ Pluggable state backends for the [ask-rb](https://github.com/ask-rb) ecosystem. Provides `Ask::State::Adapter` implementations for **SQLite**, **Redis**, **PostgreSQL**, and **MySQL** — one interface, four databases, zero coupling to your infrastructure.
4
+
5
+ ```ruby
6
+ # Local dev — zero config
7
+ store = Ask::State::Providers::SQLite.new
8
+
9
+ # In production with Rails
10
+ store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
11
+
12
+ # With your existing database
13
+ store = Ask::State::Providers::Postgres.new(url: ENV["DATABASE_URL"])
14
+ ```
15
+
16
+ ## Why?
17
+
18
+ ask-rb agents and sessions need to persist state — conversations, tool results, locks, task queues. Each deployment has different infrastructure: a CLI tool needs SQLite, a Rails app already has Postgres, a distributed system needs Redis. Instead of baking one backend into ask-core, this gem provides them all as drop-in adapters behind the same `Ask::State::Adapter` contract.
19
+
20
+ ## Installation
21
+
22
+ Add this line to your `Gemfile`:
23
+
24
+ ```ruby
25
+ gem "ask-state-providers"
26
+ ```
27
+
28
+ Then add the database driver for the backend you want to use:
29
+
30
+ ```ruby
31
+ # For SQLite (ships with Ruby's standard library — no extra gem needed on most systems)
32
+ gem "sqlite3"
33
+
34
+ # For Redis
35
+ gem "redis"
36
+
37
+ # For PostgreSQL
38
+ gem "pg"
39
+
40
+ # For MySQL
41
+ gem "mysql2"
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```ruby
47
+ require "ask-state-providers"
48
+
49
+ # Pick your backend:
50
+ store = Ask::State::Providers::SQLite.new(path: "my_app.db")
51
+ # store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
52
+ # store = Ask::State::Providers::Postgres.new(url: ENV["DATABASE_URL"])
53
+ # store = Ask::State::Providers::MySQL.new(url: ENV["MYSQL_URL"])
54
+
55
+ # Key-value storage
56
+ store.set("user:1", { name: "Alice", role: "admin" })
57
+ store.get("user:1") # => {"name" => "Alice", "role" => "admin"}
58
+ store.set("temp", "expires", ttl: 3600) # auto-expires in 1 hour
59
+ store.delete("user:1")
60
+
61
+ # Conditional create
62
+ store.set_if_not_exists("lock:deploy", "in_progress")
63
+
64
+ # Distributed locking
65
+ lock = store.acquire_lock("deploy-prod", ttl: 60)
66
+ store.release_lock("deploy-prod", lock) if lock
67
+
68
+ # Message queues
69
+ store.enqueue("tasks", { action: "send_email" })
70
+ task = store.dequeue("tasks")
71
+
72
+ # Ordered lists
73
+ store.list_append("recent_events", event, max_length: 100)
74
+ store.list_range("recent_events", 0, 9) # first 10
75
+ ```
76
+
77
+ ## Backends
78
+
79
+ ### SQLite (`Ask::State::Providers::SQLite`)
80
+
81
+ Best for single-process, single-user applications — CLI tools, local development, personal agents.
82
+
83
+ | Feature | Detail |
84
+ |---------|--------|
85
+ | **Driver** | [`sqlite3`](https://github.com/sparklemotion/sqlite3-ruby) |
86
+ | **Configuration** | `SQLite.new(path:)` |
87
+ | **Storage** | Single file on disk |
88
+ | **Concurrency** | WAL mode with 5-second busy timeout |
89
+ | **Tables** | `state_store`, `locks`, `queues`, `lists` (auto-created) |
90
+
91
+ Uses `INSERT OR REPLACE` for key-value, `INSERT ... WHERE NOT EXISTS` for conditional writes, and `DELETE ... RETURNING` for safe queue dequeue.
92
+
93
+ ### Redis (`Ask::State::Providers::Redis`)
94
+
95
+ Best for distributed, multi-process, or multi-host deployments.
96
+
97
+ | Feature | Detail |
98
+ |---------|--------|
99
+ | **Driver** | [`redis`](https://github.com/redis-rb/redis-rb) |
100
+ | **Configuration** | `Redis.new(url:)` |
101
+ | **Storage** | In-memory with optional persistence |
102
+ | **Key prefix** | `ask:state:` (all keys are namespaced) |
103
+
104
+ Leverages Redis-native primitives: `SET NX EX` for atomic locking with auto-expire, `RPUSH`/`LPOP` for FIFO queues, `LTRIM` for bounded lists, Lua `EVAL` for safe lock release.
105
+
106
+ ### PostgreSQL (`Ask::State::Providers::Postgres`)
107
+
108
+ Best for Rails apps and deployments already running Postgres.
109
+
110
+ | Feature | Detail |
111
+ |---------|--------|
112
+ | **Driver** | [`pg`](https://github.com/ged/ruby-pg) |
113
+ | **Configuration** | `Postgres.new(url:)` |
114
+ | **Connection pool** | Built-in via `connection_pool` (default pool size: 5) |
115
+
116
+ Uses `ON CONFLICT`, `RETURNING`, and `INSERT ... WHERE NOT EXISTS` for safe concurrent access.
117
+
118
+ ### MySQL (`Ask::State::Providers::MySQL`)
119
+
120
+ Best for teams already running MySQL or MariaDB.
121
+
122
+ | Feature | Detail |
123
+ |---------|--------|
124
+ | **Driver** | [`mysql2`](https://github.com/brianmario/mysql2) |
125
+ | **Configuration** | `MySQL.new(url:)` |
126
+ | **Character set** | `utf8mb4` (full Unicode including emoji) |
127
+
128
+ Uses prepared statements, `ON DUPLICATE KEY UPDATE`, and `SELECT ... LIMIT 1` for safe dequeue.
129
+
130
+ ## API Reference
131
+
132
+ All backends implement `Ask::State::Adapter`:
133
+
134
+ ### Key-Value
135
+
136
+ | Method | Description |
137
+ |--------|-------------|
138
+ | `get(key)` | Retrieve a value, or `nil` if missing or expired |
139
+ | `set(key, value, ttl:)` | Store a value (JSON-serializable). `ttl` in seconds |
140
+ | `delete(key)` | Remove a key |
141
+ | `set_if_not_exists(key, value, ttl:)` | Create only if key doesn't exist (or is expired). Returns `true`/`false` |
142
+ | `clear` | Remove all keys |
143
+
144
+ ### Distributed Locking
145
+
146
+ | Method | Description |
147
+ |--------|-------------|
148
+ | `acquire_lock(key, ttl:)` | Acquire a lock. Returns `Lock` or `nil` |
149
+ | `release_lock(key, lock)` | Release a lock (only the owner can). Returns `true`/`false` |
150
+
151
+ ### Message Queues
152
+
153
+ | Method | Description |
154
+ |--------|-------------|
155
+ | `enqueue(queue, value)` | Push to the back of a queue. Returns `QueueEntry` |
156
+ | `dequeue(queue)` | Pop from the front of a queue. Returns `QueueEntry` or `nil` |
157
+ | `queue_depth(queue)` | Number of items in the queue |
158
+
159
+ ### Ordered Lists
160
+
161
+ | Method | Description |
162
+ |--------|-------------|
163
+ | `list_append(key, value, max_length:)` | Append to list. Trims to `max_length` (keeps newest) |
164
+ | `list_range(key, start, stop)` | Slice of the list. `stop = -1` means all |
165
+ | `list_remove(key, value)` | Remove all occurrences. Returns count removed |
166
+
167
+ ### Lifecycle
168
+
169
+ | Method | Description |
170
+ |--------|-------------|
171
+ | `close` | Close the connection(s) |
172
+
173
+ ## Using with ask-agent Sessions
174
+
175
+ ```ruby
176
+ require "ask-state-providers"
177
+ require "ask-agent"
178
+
179
+ store = Ask::State::Providers::SQLite.new
180
+
181
+ session = Ask::Agent::Session.new(
182
+ "triage",
183
+ model: "gpt-4o",
184
+ state: store
185
+ )
186
+
187
+ session.run("What happened last time we saw this error?")
188
+ # Every turn is persisted — survive restarts, searchable, auditable
189
+ ```
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ # Install dependencies
195
+ bundle install
196
+
197
+ # Run tests (SQLite tests run everywhere, Redis needs fakeredis,
198
+ # Postgres/MySQL need DATABASE_URL/MYSQL_URL env vars)
199
+ bundle exec rake test
200
+
201
+ # Run tests with verbose output
202
+ bundle exec ruby -Itest test/ask/state/providers/sqlite_test.rb
203
+ bundle exec ruby -Itest test/ask/state/providers/redis_test.rb
204
+
205
+ # Test Postgres locally
206
+ DATABASE_URL="postgres://localhost:5432/ask_state_test" bundle exec rake test
207
+
208
+ # Test MySQL locally
209
+ MYSQL_URL="mysql2://root@localhost:3306/ask_state_test" bundle exec rake test
210
+ ```
211
+
212
+ ## License
213
+
214
+ MIT — see [LICENSE](LICENSE).
215
+
216
+ ## Links
217
+
218
+ - **Source:** https://github.com/ask-rb/ask-state-providers
219
+ - **Issues:** https://github.com/ask-rb/ask-state-providers/issues
220
+ - **Docs:** https://github.com/ask-rb/ask-docs
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+
7
+ module Ask
8
+ module State
9
+ module Providers
10
+ # A persistent key-value store backed by MySQL.
11
+ #
12
+ # Implements the full {Ask::State::Adapter} contract:
13
+ # key-value storage, distributed locking, message queues, and ordered
14
+ # lists. Uses the +mysql2+ gem for database access.
15
+ #
16
+ # @example
17
+ # store = Ask::State::Providers::MySQL.new(
18
+ # url: ENV["MYSQL_URL"]
19
+ # )
20
+ # store.set("key", { hello: "world" })
21
+ # store.get("key") # => { "hello" => "world" }
22
+ #
23
+ class MySQL < ::Ask::State::Adapter
24
+ MIGRATIONS = <<~SQL
25
+ CREATE TABLE IF NOT EXISTS state_store (
26
+ `key` VARCHAR(255) PRIMARY KEY NOT NULL,
27
+ `value` TEXT NOT NULL,
28
+ expires_at DATETIME(3) NULL
29
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
30
+
31
+ CREATE TABLE IF NOT EXISTS locks (
32
+ `key` VARCHAR(255) PRIMARY KEY NOT NULL,
33
+ token VARCHAR(64) NOT NULL,
34
+ expires_at DATETIME(3) NOT NULL
35
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
36
+
37
+ CREATE TABLE IF NOT EXISTS queues (
38
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
39
+ queue_name VARCHAR(255) NOT NULL,
40
+ `value` TEXT NOT NULL,
41
+ enqueued_at DATETIME(3) NOT NULL,
42
+ INDEX idx_queues_queue_name (queue_name, id)
43
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
44
+
45
+ CREATE TABLE IF NOT EXISTS lists (
46
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
47
+ list_key VARCHAR(255) NOT NULL,
48
+ `value` TEXT NOT NULL,
49
+ INDEX idx_lists_list_key (list_key, id)
50
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
51
+ SQL
52
+
53
+ # Creates a new MySQL-backed state store.
54
+ #
55
+ # @param url [String] MySQL connection URL
56
+ def initialize(url: ENV.fetch("MYSQL_URL", "mysql2://root@localhost:3306/ask_state"))
57
+ require "mysql2"
58
+
59
+ uri = URI.parse(url)
60
+ @client = Mysql2::Client.new(
61
+ host: uri.host || "localhost",
62
+ port: uri.port || 3306,
63
+ username: uri.user || "root",
64
+ password: uri.password,
65
+ database: uri.path.sub("/", ""),
66
+ charset: "utf8mb4"
67
+ )
68
+
69
+ migrate
70
+ end
71
+
72
+ # -- key-value --
73
+
74
+ def get(key)
75
+ row = @client.prepare(<<~SQL).execute(key, Time.now.utc.strftime("%Y-%m-%d %H:%M:%S.%3N")).first
76
+ SELECT `value` FROM state_store
77
+ WHERE `key` = ? AND (expires_at IS NULL OR expires_at > ?)
78
+ SQL
79
+ row ? JSON.parse(row["value"]) : nil
80
+ end
81
+
82
+ def set(key, value, ttl: nil)
83
+ stmt = @client.prepare(<<~SQL)
84
+ INSERT INTO state_store (`key`, `value`, expires_at)
85
+ VALUES (?, ?, ?)
86
+ ON DUPLICATE KEY UPDATE
87
+ `value` = VALUES(`value`),
88
+ expires_at = VALUES(expires_at)
89
+ SQL
90
+ stmt.execute(key, JSON.generate(value), ttl ? (Time.now.utc + ttl).strftime("%Y-%m-%d %H:%M:%S.%3N") : nil)
91
+ end
92
+
93
+ def delete(key)
94
+ @client.prepare("DELETE FROM state_store WHERE `key` = ?").execute(key)
95
+ end
96
+
97
+ def set_if_not_exists(key, value, ttl: nil)
98
+ now = Time.now.utc
99
+ expires = ttl ? (now + ttl).strftime("%Y-%m-%d %H:%M:%S.%3N") : nil
100
+ now_f = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
101
+
102
+ stmt = @client.prepare(<<~SQL)
103
+ INSERT INTO state_store (`key`, `value`, expires_at)
104
+ SELECT ?, ?, ?
105
+ WHERE NOT EXISTS (
106
+ SELECT 1 FROM state_store
107
+ WHERE `key` = ? AND (expires_at IS NULL OR expires_at > ?)
108
+ )
109
+ SQL
110
+ # MySQL's INSERT...SELECT won't fail on DUPLICATE KEY if WHERE is false
111
+ stmt.execute(key, JSON.generate(value), expires, key, now_f)
112
+ @client.affected_rows > 0
113
+ end
114
+
115
+ def clear
116
+ @client.query("DELETE FROM state_store")
117
+ end
118
+
119
+ # -- distributed locking --
120
+
121
+ def acquire_lock(key, ttl: 10)
122
+ now = Time.now.utc
123
+ expires_at_time = now + ttl
124
+ token = SecureRandom.hex(16)
125
+ now_f = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
126
+
127
+ # Clean up expired lock
128
+ @client.prepare("DELETE FROM locks WHERE `key` = ? AND expires_at <= ?").execute(key, now_f)
129
+
130
+ result = @client.prepare(<<~SQL).execute(key, expires_at_time.strftime("%Y-%m-%d %H:%M:%S.%3N"), token)
131
+ INSERT INTO locks (`key`, expires_at, token)
132
+ SELECT ?, ?, ?
133
+ WHERE NOT EXISTS (
134
+ SELECT 1 FROM locks WHERE `key` = ?
135
+ )
136
+ SQL
137
+ result = @client.prepare(<<~SQL).execute(key)
138
+ SELECT COUNT(*) AS cnt FROM locks WHERE `key` = ?
139
+ SQL
140
+
141
+ # Check if we got the lock
142
+ row = result.first
143
+ return nil unless row && row["cnt"].to_i > 0
144
+
145
+ Lock.new(id: key, token: token, expires_at: expires_at_time)
146
+ end
147
+
148
+ def release_lock(key, lock)
149
+ @client.prepare("DELETE FROM locks WHERE `key` = ? AND token = ?").execute(key, lock.token)
150
+ @client.affected_rows > 0
151
+ end
152
+
153
+ # -- message queues --
154
+
155
+ def enqueue(queue, value)
156
+ stmt = @client.prepare(<<~SQL)
157
+ INSERT INTO queues (queue_name, `value`, enqueued_at)
158
+ VALUES (?, ?, ?)
159
+ SQL
160
+ stmt.execute(queue, JSON.generate(value), Time.now.utc.strftime("%Y-%m-%d %H:%M:%S.%3N"))
161
+ id = @client.last_id
162
+
163
+ QueueEntry.new(id: id.to_s, value: value, enqueued_at: Time.now)
164
+ end
165
+
166
+ def dequeue(queue)
167
+ row = @client.prepare(<<~SQL).execute(queue).first
168
+ SELECT id, `value`, enqueued_at FROM queues
169
+ WHERE queue_name = ?
170
+ ORDER BY id ASC
171
+ LIMIT 1
172
+ SQL
173
+ return nil unless row
174
+
175
+ @client.prepare("DELETE FROM queues WHERE id = ?").execute(row["id"])
176
+
177
+ QueueEntry.new(
178
+ id: row["id"].to_s,
179
+ value: JSON.parse(row["value"]),
180
+ enqueued_at: Time.parse(row["enqueued_at"])
181
+ )
182
+ end
183
+
184
+ def queue_depth(queue)
185
+ result = @client.prepare("SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = ?").execute(queue)
186
+ row = result.first
187
+ row["cnt"]
188
+ end
189
+
190
+ # -- ordered lists --
191
+
192
+ def list_append(key, value, max_length: nil)
193
+ stmt = @client.prepare("INSERT INTO lists (list_key, `value`) VALUES (?, ?)")
194
+ stmt.execute(key, JSON.generate(value))
195
+
196
+ return unless max_length
197
+
198
+ @client.prepare(<<~SQL).execute(key, key, max_length)
199
+ DELETE FROM lists WHERE id <= (
200
+ SELECT COALESCE(MIN(id), 0) FROM (
201
+ SELECT id FROM lists
202
+ WHERE list_key = ?
203
+ ORDER BY id DESC
204
+ LIMIT ?
205
+ ) sub
206
+ )
207
+ SQL
208
+ end
209
+
210
+ def list_range(key, start = 0, stop = -1)
211
+ rows = if stop == -1
212
+ @client.prepare(<<~SQL).execute(key, start)
213
+ SELECT `value` FROM lists
214
+ WHERE list_key = ?
215
+ ORDER BY id ASC
216
+ LIMIT 18446744073709551615 OFFSET ?
217
+ SQL
218
+ else
219
+ limit = stop - start + 1
220
+ @client.prepare(<<~SQL).execute(key, limit, start)
221
+ SELECT `value` FROM lists
222
+ WHERE list_key = ?
223
+ ORDER BY id ASC
224
+ LIMIT ? OFFSET ?
225
+ SQL
226
+ end
227
+ rows.map { |r| JSON.parse(r["value"]) }
228
+ end
229
+
230
+ def list_remove(key, value)
231
+ serialized = JSON.generate(value)
232
+ @client.prepare("DELETE FROM lists WHERE list_key = ? AND `value` = ?").execute(key, serialized)
233
+ @client.affected_rows
234
+ end
235
+
236
+ # -- lifecycle --
237
+
238
+ def close
239
+ @client&.close
240
+ end
241
+
242
+ private
243
+
244
+ def migrate
245
+ MIGRATIONS.split(";").each do |sql|
246
+ sql = sql.strip
247
+ @client.query(sql) unless sql.empty?
248
+ end
249
+ rescue => e
250
+ # Tables may already exist with different charset — that's fine
251
+ raise unless e.message.include?("already exists")
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+
7
+ module Ask
8
+ module State
9
+ module Providers
10
+ # A persistent key-value store backed by PostgreSQL.
11
+ #
12
+ # Implements the full {Ask::State::Adapter} contract:
13
+ # key-value storage, distributed locking, message queues, and ordered
14
+ # lists. Uses the +pg+ gem for database access.
15
+ #
16
+ # @example
17
+ # store = Ask::State::Providers::Postgres.new(
18
+ # url: ENV["DATABASE_URL"]
19
+ # )
20
+ # store.set("key", { hello: "world" })
21
+ # store.get("key") # => { "hello" => "world" }
22
+ #
23
+ class Postgres < ::Ask::State::Adapter
24
+ MIGRATIONS = <<~SQL
25
+ CREATE TABLE IF NOT EXISTS state_store (
26
+ key TEXT PRIMARY KEY NOT NULL,
27
+ value TEXT NOT NULL,
28
+ expires_at TIMESTAMPTZ
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS locks (
32
+ key TEXT PRIMARY KEY NOT NULL,
33
+ token TEXT NOT NULL,
34
+ expires_at TIMESTAMPTZ NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS queues (
38
+ id BIGSERIAL PRIMARY KEY,
39
+ queue_name TEXT NOT NULL,
40
+ value TEXT NOT NULL,
41
+ enqueued_at TIMESTAMPTZ NOT NULL
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_queues_queue_name
44
+ ON queues (queue_name, id);
45
+
46
+ CREATE TABLE IF NOT EXISTS lists (
47
+ id BIGSERIAL PRIMARY KEY,
48
+ list_key TEXT NOT NULL,
49
+ value TEXT NOT NULL
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_lists_list_key
52
+ ON lists (list_key, id);
53
+ SQL
54
+
55
+ # Creates a new PostgreSQL-backed state store.
56
+ #
57
+ # @param url [String] Postgres connection URL
58
+ # @param pool_size [Integer] connection pool size (default 5)
59
+ def initialize(url: ENV.fetch("DATABASE_URL", "postgres://localhost:5432/ask_state"),
60
+ pool_size: 5)
61
+ require "pg"
62
+
63
+ @pool = ConnectionPool.new(size: pool_size) do
64
+ ::PG.connect(url)
65
+ end
66
+
67
+ migrate
68
+ end
69
+
70
+ # -- key-value --
71
+
72
+ def get(key)
73
+ @pool.with do |conn|
74
+ row = conn.exec_params(<<~SQL, [key, Time.now.utc])
75
+ SELECT value FROM state_store
76
+ WHERE key = $1 AND (expires_at IS NULL OR expires_at > $2)
77
+ SQL
78
+ row.ntuples > 0 ? JSON.parse(row[0]["value"]) : nil
79
+ end
80
+ end
81
+
82
+ def set(key, value, ttl: nil)
83
+ @pool.with do |conn|
84
+ conn.exec_params(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.utc + ttl : nil])
85
+ INSERT INTO state_store (key, value, expires_at)
86
+ VALUES ($1, $2, $3)
87
+ ON CONFLICT (key) DO UPDATE SET
88
+ value = EXCLUDED.value,
89
+ expires_at = EXCLUDED.expires_at
90
+ SQL
91
+ end
92
+ end
93
+
94
+ def delete(key)
95
+ @pool.with do |conn|
96
+ conn.exec_params("DELETE FROM state_store WHERE key = $1", [key])
97
+ end
98
+ end
99
+
100
+ def set_if_not_exists(key, value, ttl: nil)
101
+ @pool.with do |conn|
102
+ now = Time.now.utc
103
+ expires = ttl ? now + ttl : nil
104
+
105
+ result = conn.exec_params(<<~SQL, [key, JSON.generate(value), expires, now])
106
+ INSERT INTO state_store (key, value, expires_at)
107
+ SELECT $1, $2, $3
108
+ WHERE NOT EXISTS (
109
+ SELECT 1 FROM state_store
110
+ WHERE key = $1 AND (expires_at IS NULL OR expires_at > $4)
111
+ )
112
+ ON CONFLICT (key) DO NOTHING
113
+ SQL
114
+ result.cmd_tuples > 0
115
+ end
116
+ end
117
+
118
+ def clear
119
+ @pool.with { |conn| conn.exec("DELETE FROM state_store") }
120
+ end
121
+
122
+ # -- distributed locking --
123
+
124
+ def acquire_lock(key, ttl: 10)
125
+ now = Time.now.utc
126
+ expires_at_time = now + ttl
127
+ token = SecureRandom.hex(16)
128
+
129
+ acquired = @pool.with do |conn|
130
+ # First clean up expired locks
131
+ conn.exec_params("DELETE FROM locks WHERE key = $1 AND expires_at <= $2",
132
+ [key, now])
133
+
134
+ result = conn.exec_params(<<~SQL, [key, expires_at_time, token, now])
135
+ INSERT INTO locks (key, expires_at, token)
136
+ SELECT $1, $2, $3
137
+ WHERE NOT EXISTS (
138
+ SELECT 1 FROM locks
139
+ WHERE key = $1
140
+ )
141
+ SQL
142
+ result.cmd_tuples > 0
143
+ end
144
+
145
+ acquired ? Lock.new(id: key, token: token, expires_at: expires_at_time) : nil
146
+ end
147
+
148
+ def release_lock(key, lock)
149
+ @pool.with do |conn|
150
+ result = conn.exec_params(
151
+ "DELETE FROM locks WHERE key = $1 AND token = $2",
152
+ [key, lock.token]
153
+ )
154
+ result.cmd_tuples > 0
155
+ end
156
+ end
157
+
158
+ # -- message queues --
159
+
160
+ def enqueue(queue, value)
161
+ id = SecureRandom.uuid
162
+
163
+ @pool.with do |conn|
164
+ conn.exec_params(<<~SQL, [queue, JSON.generate(value), Time.now.utc])
165
+ INSERT INTO queues (queue_name, value, enqueued_at)
166
+ VALUES ($1, $2, $3)
167
+ SQL
168
+ end
169
+
170
+ QueueEntry.new(id: id, value: value, enqueued_at: Time.now)
171
+ end
172
+
173
+ def dequeue(queue)
174
+ @pool.with do |conn|
175
+ result = conn.exec_params(<<~SQL, [queue])
176
+ DELETE FROM queues
177
+ WHERE id = (
178
+ SELECT id FROM queues
179
+ WHERE queue_name = $1
180
+ ORDER BY id ASC
181
+ LIMIT 1
182
+ )
183
+ RETURNING value, enqueued_at
184
+ SQL
185
+ return nil if result.ntuples == 0
186
+
187
+ QueueEntry.new(
188
+ id: SecureRandom.uuid,
189
+ value: JSON.parse(result[0]["value"]),
190
+ enqueued_at: Time.parse(result[0]["enqueued_at"])
191
+ )
192
+ end
193
+ end
194
+
195
+ def queue_depth(queue)
196
+ @pool.with do |conn|
197
+ result = conn.exec_params(
198
+ "SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = $1", [queue]
199
+ )
200
+ result[0]["cnt"].to_i
201
+ end
202
+ end
203
+
204
+ # -- ordered lists --
205
+
206
+ def list_append(key, value, max_length: nil)
207
+ @pool.with do |conn|
208
+ conn.exec_params(<<~SQL, [key, JSON.generate(value)])
209
+ INSERT INTO lists (list_key, value)
210
+ VALUES ($1, $2)
211
+ SQL
212
+
213
+ return unless max_length
214
+
215
+ conn.exec_params(<<~SQL, [key, key, max_length])
216
+ DELETE FROM lists WHERE id <= (
217
+ SELECT COALESCE(MIN(id), 0) FROM (
218
+ SELECT id FROM lists
219
+ WHERE list_key = $1
220
+ ORDER BY id DESC
221
+ LIMIT $3
222
+ ) sub
223
+ )
224
+ SQL
225
+ end
226
+ end
227
+
228
+ def list_range(key, start = 0, stop = -1)
229
+ @pool.with do |conn|
230
+ rows = if stop == -1
231
+ conn.exec_params(<<~SQL, [key, start])
232
+ SELECT value FROM lists
233
+ WHERE list_key = $1
234
+ ORDER BY id ASC
235
+ OFFSET $2
236
+ SQL
237
+ else
238
+ limit = stop - start + 1
239
+ conn.exec_params(<<~SQL, [key, limit, start])
240
+ SELECT value FROM lists
241
+ WHERE list_key = $1
242
+ ORDER BY id ASC
243
+ LIMIT $2 OFFSET $3
244
+ SQL
245
+ end
246
+ rows.map { |r| JSON.parse(r["value"]) }
247
+ end
248
+ end
249
+
250
+ def list_remove(key, value)
251
+ serialized = JSON.generate(value)
252
+ @pool.with do |conn|
253
+ result = conn.exec_params(
254
+ "DELETE FROM lists WHERE list_key = $1 AND value = $2",
255
+ [key, serialized]
256
+ )
257
+ result.cmd_tuples
258
+ end
259
+ end
260
+
261
+ # -- lifecycle --
262
+
263
+ def close
264
+ @pool&.shutdown { |c| c.close }
265
+ end
266
+
267
+ private
268
+
269
+ def migrate
270
+ @pool.with { |conn| conn.exec(MIGRATIONS) }
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Ask
7
+ module State
8
+ module Providers
9
+ # A distributed key-value store backed by Redis.
10
+ #
11
+ # Implements the full {Ask::State::Adapter} contract:
12
+ # key-value storage, distributed locking, message queues, and ordered
13
+ # lists. Best suited for multi-process or multi-host deployments where
14
+ # shared state is required.
15
+ #
16
+ # @example
17
+ # store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
18
+ # store.set("key", { hello: "world" })
19
+ # store.get("key") # => { "hello" => "world" }
20
+ #
21
+ class Redis < ::Ask::State::Adapter
22
+ NAMESPACE = "ask:state"
23
+
24
+ # Creates a new Redis-backed state store.
25
+ #
26
+ # @param url [String] Redis URL (redis://...)
27
+ def initialize(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))
28
+ require "redis"
29
+
30
+ @redis = ::Redis.new(url: url)
31
+ end
32
+
33
+ # -- key-value --
34
+
35
+ def get(key)
36
+ val = @redis.call("GET", prefixed(key))
37
+ val ? JSON.parse(val) : nil
38
+ end
39
+
40
+ def set(key, value, ttl: nil)
41
+ args = [prefixed(key), JSON.generate(value)]
42
+ args.concat(["EX", ttl.to_i]) if ttl
43
+ @redis.call("SET", *args)
44
+ end
45
+
46
+ def delete(key)
47
+ @redis.call("DEL", prefixed(key))
48
+ end
49
+
50
+ def set_if_not_exists(key, value, ttl: nil)
51
+ args = [prefixed(key), JSON.generate(value)]
52
+ args.concat(["EX", ttl.to_i]) if ttl
53
+ @redis.call("SET", *args, "NX") == "OK"
54
+ end
55
+
56
+ def clear
57
+ keys = @redis.call("KEYS", "#{NAMESPACE}:*")
58
+ @redis.call("DEL", *keys) if keys.any?
59
+ end
60
+
61
+ # -- distributed locking --
62
+
63
+ def acquire_lock(key, ttl: 10)
64
+ prefixed_key = prefixed("lock:#{key}")
65
+ token = SecureRandom.hex(16)
66
+ expires_at = Time.now + ttl
67
+
68
+ acquired = @redis.call("SET", prefixed_key, token, "NX", "EX", ttl) == "OK"
69
+ acquired ? Lock.new(id: key, token: token, expires_at: expires_at) : nil
70
+ end
71
+
72
+ def release_lock(key, lock)
73
+ prefixed_key = prefixed("lock:#{key}")
74
+
75
+ # Atomic release: only delete if token matches (Lua script)
76
+ script = <<~LUA
77
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
78
+ return redis.call("DEL", KEYS[1])
79
+ else
80
+ return 0
81
+ end
82
+ LUA
83
+
84
+ @redis.call("EVAL", script, [1], [prefixed_key, lock.token]) == 1
85
+ end
86
+
87
+ # -- message queues --
88
+
89
+ def enqueue(queue, value)
90
+ id = SecureRandom.uuid
91
+ entry = { id: id, value: value, enqueued_at: Time.now.iso8601 }
92
+
93
+ @redis.call("RPUSH", prefixed("queue:#{queue}"), JSON.generate(entry))
94
+
95
+ QueueEntry.new(id: id, value: value, enqueued_at: Time.now)
96
+ end
97
+
98
+ def dequeue(queue)
99
+ val = @redis.call("LPOP", prefixed("queue:#{queue}"))
100
+ return nil unless val
101
+
102
+ entry = JSON.parse(val)
103
+ QueueEntry.new(
104
+ id: entry["id"],
105
+ value: entry["value"],
106
+ enqueued_at: Time.parse(entry["enqueued_at"])
107
+ )
108
+ end
109
+
110
+ def queue_depth(queue)
111
+ @redis.call("LLEN", prefixed("queue:#{queue}"))
112
+ end
113
+
114
+ # -- ordered lists --
115
+
116
+ def list_append(key, value, max_length: nil)
117
+ @redis.call("RPUSH", prefixed("list:#{key}"), JSON.generate(value))
118
+ return unless max_length
119
+
120
+ # LTRIM keeps only the specified range (negative indices, so -max_length means keep newest max_length)
121
+ @redis.call("LTRIM", prefixed("list:#{key}"), -max_length, -1)
122
+ end
123
+
124
+ def list_range(key, start = 0, stop = -1)
125
+ items = @redis.call("LRANGE", prefixed("list:#{key}"), start, stop)
126
+ items.map { |item| JSON.parse(item) }
127
+ end
128
+
129
+ def list_remove(key, value)
130
+ serialized = JSON.generate(value)
131
+ @redis.call("LREM", prefixed("list:#{key}"), 0, serialized)
132
+ end
133
+
134
+ # -- lifecycle --
135
+
136
+ def close
137
+ @redis&.close
138
+ end
139
+
140
+ private
141
+
142
+ def prefixed(key)
143
+ "#{NAMESPACE}:#{key}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+
7
+ module Ask
8
+ module State
9
+ module Providers
10
+ # A persistent key-value store backed by SQLite.
11
+ #
12
+ # Implements the full {Ask::State::Adapter} contract:
13
+ # key-value storage, distributed locking, message queues, and ordered
14
+ # lists. Uses the +sqlite3+ gem.
15
+ #
16
+ # @example
17
+ # store = Ask::State::Providers::SQLite.new(path: "sessions.db")
18
+ # store.set("key", { hello: "world" })
19
+ # store.get("key") # => { "hello" => "world" }
20
+ #
21
+ class SQLite < ::Ask::State::Adapter
22
+ # Creates or opens the database at +path+.
23
+ def initialize(path: "sessions.db", **pragmas)
24
+ require "sqlite3"
25
+
26
+ @db = SQLite3::Database.new(path)
27
+ @db.results_as_hash = true
28
+ @db.busy_timeout = 5000
29
+
30
+ defaults = {
31
+ journal_mode: "WAL",
32
+ synchronous: "NORMAL",
33
+ foreign_keys: "ON",
34
+ cache_size: -64_000
35
+ }
36
+
37
+ defaults.merge(pragmas).each do |key, value|
38
+ @db.execute("PRAGMA #{key} = #{value}")
39
+ end
40
+
41
+ migrate
42
+ end
43
+
44
+ # -- key-value --
45
+
46
+ def get(key)
47
+ row = @db.get_first_row(<<~SQL, [key, Time.now.to_f])
48
+ SELECT value FROM state_store
49
+ WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
50
+ SQL
51
+ row ? JSON.parse(row["value"]) : nil
52
+ end
53
+
54
+ def set(key, value, ttl: nil)
55
+ @db.execute(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.to_f + ttl : nil])
56
+ INSERT OR REPLACE INTO state_store (key, value, expires_at)
57
+ VALUES (?, ?, ?)
58
+ SQL
59
+ end
60
+
61
+ def delete(key)
62
+ @db.execute("DELETE FROM state_store WHERE key = ?", [key])
63
+ end
64
+
65
+ def set_if_not_exists(key, value, ttl: nil)
66
+ now = Time.now.to_f
67
+ expires = ttl ? now + ttl : nil
68
+
69
+ @db.transaction do
70
+ row = @db.get_first_row(
71
+ "SELECT 1 FROM state_store WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)",
72
+ [key, now]
73
+ )
74
+ next false if row
75
+
76
+ @db.execute("DELETE FROM state_store WHERE key = ?", [key])
77
+ @db.execute(<<~SQL, [key, JSON.generate(value), expires])
78
+ INSERT INTO state_store (key, value, expires_at)
79
+ VALUES (?, ?, ?)
80
+ SQL
81
+ next true
82
+ end
83
+ end
84
+
85
+ def clear
86
+ @db.execute("DELETE FROM state_store")
87
+ end
88
+
89
+ # -- distributed locking --
90
+
91
+ def acquire_lock(key, ttl: 10)
92
+ now = Time.now.to_f
93
+ expires_at_time = Time.now + ttl
94
+ token = SecureRandom.hex(16)
95
+
96
+ acquired = @db.transaction do
97
+ row = @db.get_first_row(
98
+ "SELECT 1 FROM locks WHERE key = ? AND expires_at > ?",
99
+ [key, now]
100
+ )
101
+ next false if row
102
+
103
+ @db.execute("DELETE FROM locks WHERE key = ?", [key])
104
+ @db.execute(<<~SQL, [key, expires_at_time.to_f, token])
105
+ INSERT INTO locks (key, expires_at, token)
106
+ VALUES (?, ?, ?)
107
+ SQL
108
+ next true
109
+ end
110
+
111
+ acquired ? Lock.new(id: key, token: token, expires_at: expires_at_time) : nil
112
+ end
113
+
114
+ def release_lock(key, lock)
115
+ @db.execute(
116
+ "DELETE FROM locks WHERE key = ? AND token = ?",
117
+ [key, lock.token]
118
+ )
119
+ @db.changes > 0
120
+ end
121
+
122
+ # -- message queues --
123
+
124
+ def enqueue(queue, value)
125
+ @db.execute(<<~SQL, [queue, JSON.generate(value), Time.now.iso8601])
126
+ INSERT INTO queues (queue_name, value, enqueued_at)
127
+ VALUES (?, ?, ?)
128
+ SQL
129
+ id = @db.last_insert_row_id
130
+ QueueEntry.new(id: id.to_s, value: value, enqueued_at: Time.now)
131
+ end
132
+
133
+ def dequeue(queue)
134
+ row = @db.get_first_row(<<~SQL, [queue])
135
+ DELETE FROM queues
136
+ WHERE id = (
137
+ SELECT id FROM queues
138
+ WHERE queue_name = ?
139
+ ORDER BY id ASC
140
+ LIMIT 1
141
+ )
142
+ RETURNING id, value, enqueued_at
143
+ SQL
144
+ return nil unless row
145
+
146
+ QueueEntry.new(
147
+ id: row["id"].to_s,
148
+ value: JSON.parse(row["value"]),
149
+ enqueued_at: Time.parse(row["enqueued_at"])
150
+ )
151
+ end
152
+
153
+ def queue_depth(queue)
154
+ row = @db.get_first_row(
155
+ "SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = ?", [queue]
156
+ )
157
+ row["cnt"]
158
+ end
159
+
160
+ # -- ordered lists --
161
+
162
+ def list_append(key, value, max_length: nil)
163
+ serialized = JSON.generate(value)
164
+
165
+ @db.execute(<<~SQL, [key, serialized])
166
+ INSERT INTO lists (list_key, value)
167
+ VALUES (?, ?)
168
+ SQL
169
+
170
+ return unless max_length
171
+
172
+ # Keep only the newest max_length items by deleting those with the
173
+ # smallest id values beyond the threshold
174
+ row = @db.get_first_row(<<~SQL, [key, max_length])
175
+ SELECT MIN(id) AS cutoff FROM (
176
+ SELECT id FROM lists
177
+ WHERE list_key = ?
178
+ ORDER BY id DESC
179
+ LIMIT ?
180
+ )
181
+ SQL
182
+ return unless row && row["cutoff"]
183
+
184
+ @db.execute(<<~SQL, [key, row["cutoff"]])
185
+ DELETE FROM lists WHERE list_key = ? AND id < ?
186
+ SQL
187
+ end
188
+
189
+ def list_range(key, start = 0, stop = -1)
190
+ if stop == -1
191
+ rows = @db.execute(<<~SQL, [key, start])
192
+ SELECT value FROM lists
193
+ WHERE list_key = ?
194
+ ORDER BY id ASC
195
+ LIMIT -1 OFFSET ?
196
+ SQL
197
+ else
198
+ limit = stop - start + 1
199
+ rows = @db.execute(<<~SQL, [key, limit, start])
200
+ SELECT value FROM lists
201
+ WHERE list_key = ?
202
+ ORDER BY id ASC
203
+ LIMIT ? OFFSET ?
204
+ SQL
205
+ end
206
+ rows.map { |r| JSON.parse(r["value"]) }
207
+ end
208
+
209
+ def list_remove(key, value)
210
+ serialized = JSON.generate(value)
211
+ @db.execute(
212
+ "DELETE FROM lists WHERE list_key = ? AND value = ?",
213
+ [key, serialized]
214
+ )
215
+ @db.changes
216
+ end
217
+
218
+ # -- lifecycle --
219
+
220
+ def close
221
+ @db.close
222
+ end
223
+
224
+ private
225
+
226
+ def migrate
227
+ @db.execute_batch(<<~SQL)
228
+ CREATE TABLE IF NOT EXISTS state_store (
229
+ key TEXT PRIMARY KEY NOT NULL,
230
+ value TEXT NOT NULL,
231
+ expires_at REAL
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS locks (
235
+ key TEXT PRIMARY KEY NOT NULL,
236
+ token TEXT NOT NULL,
237
+ expires_at REAL NOT NULL
238
+ );
239
+
240
+ CREATE TABLE IF NOT EXISTS queues (
241
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
242
+ queue_name TEXT NOT NULL,
243
+ value TEXT NOT NULL,
244
+ enqueued_at TEXT NOT NULL
245
+ );
246
+ CREATE INDEX IF NOT EXISTS idx_queues_queue_name
247
+ ON queues (queue_name, id);
248
+
249
+ CREATE TABLE IF NOT EXISTS lists (
250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
251
+ list_key TEXT NOT NULL,
252
+ value TEXT NOT NULL
253
+ );
254
+ CREATE INDEX IF NOT EXISTS idx_lists_list_key
255
+ ON lists (list_key, id);
256
+ SQL
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module State
5
+ module Providers
6
+ VERSION = "0.2.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask"
4
+ require "ask/state"
5
+
6
+ module Ask
7
+ module State
8
+ module Providers
9
+ autoload :SQLite, "ask/state/providers/sqlite"
10
+ autoload :Redis, "ask/state/providers/redis"
11
+ autoload :Postgres, "ask/state/providers/postgres"
12
+ autoload :MySQL, "ask/state/providers/mysql"
13
+ end
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-state-providers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.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: ask-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: Provides Ask::State adapter implementations for SQLite, Redis, PostgreSQL,
69
+ and MySQL. Each backend implements the Ask::State::Adapter contract (get/set/delete,
70
+ distributed locking, message queues, and ordered lists) so agents, sessions, and
71
+ workflows pick the right persistence layer with one line of code.
72
+ email:
73
+ - kaka@myrrlabs.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - lib/ask-state-providers.rb
82
+ - lib/ask/state/providers/mysql.rb
83
+ - lib/ask/state/providers/postgres.rb
84
+ - lib/ask/state/providers/redis.rb
85
+ - lib/ask/state/providers/sqlite.rb
86
+ - lib/ask/state/providers/version.rb
87
+ homepage: https://github.com/ask-rb/ask-state-providers
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/ask-rb/ask-state-providers
92
+ source_code_uri: https://github.com/ask-rb/ask-state-providers
93
+ changelog_uri: https://github.com/ask-rb/ask-state-providers/blob/master/CHANGELOG.md
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.2'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 4.0.3
109
+ specification_version: 4
110
+ summary: Pluggable state backends for the ask-rb ecosystem
111
+ test_files: []