lepus-saga-active_record 0.3.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/Gemfile +14 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +160 -0
  6. data/Rakefile +8 -0
  7. data/docs/brand/BRAND.md +67 -0
  8. data/docs/brand/apple-touch-icon.png +0 -0
  9. data/docs/brand/favicon-16.png +0 -0
  10. data/docs/brand/favicon-32.png +0 -0
  11. data/docs/brand/favicon.ico +0 -0
  12. data/docs/brand/favicon.svg +1 -0
  13. data/docs/brand/icon-512.png +0 -0
  14. data/docs/brand/lockup-lepus-mono.svg +1 -0
  15. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  17. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga.svg +1 -0
  21. data/docs/brand/lockup-lepus.svg +1 -0
  22. data/docs/brand/mark-mono.svg +1 -0
  23. data/docs/brand/mark-on-dark.svg +1 -0
  24. data/docs/brand/mark-on-light.svg +1 -0
  25. data/docs/brand/mark-small-mono.svg +1 -0
  26. data/docs/brand/mark-small-on-dark.svg +1 -0
  27. data/docs/brand/mark-small-on-light.svg +1 -0
  28. data/docs/brand/mark-small.svg +1 -0
  29. data/docs/brand/mark.svg +1 -0
  30. data/lib/generators/lepus/saga/dashboard_indexes/dashboard_indexes_generator.rb +26 -0
  31. data/lib/generators/lepus/saga/dashboard_indexes/templates/add_lepus_saga_dashboard_indexes.rb.erb +17 -0
  32. data/lib/generators/lepus/saga/install/install_generator.rb +26 -0
  33. data/lib/generators/lepus/saga/install/templates/create_lepus_saga_tables.rb.erb +52 -0
  34. data/lib/lepus/saga/active_record/execution.rb +60 -0
  35. data/lib/lepus/saga/active_record/record.rb +13 -0
  36. data/lib/lepus/saga/active_record/step_attempt.rb +30 -0
  37. data/lib/lepus/saga/active_record/version.rb +9 -0
  38. data/lib/lepus/saga/active_record.rb +20 -0
  39. data/lib/lepus/saga/stores/active_record.rb +235 -0
  40. data/mise.toml +2 -0
  41. metadata +237 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 72048dd94435e22305e296501e08f2d19234da3081fbf3c4c2fd75afdecd79f9
4
+ data.tar.gz: df6d77f4f98b6596449de33c4fba15a479d74e5c285f8cc406776727e7d72328
5
+ SHA512:
6
+ metadata.gz: '09d4fbe4123daf7310d7a387f63db9f0e9e4b4055eff5035a3bb8c7285a547d53fecc7850dab249d780fa72aa7d19fb3d8e61382b77e1f01a00c5f9b6bfc404d'
7
+ data.tar.gz: 9ace56afa8214880ae7d97b39347e6cefdd5906095befb3bbd01064b5e856fcf41555f985b8792ab7f65322ea1f4d4f218d9df637281e7e7c15950676838fc34
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 0.3.0 - Unreleased
8
+ * Added: brand mark and README logo
9
+ * Added: `Lepus::Saga::Stores::ActiveRecord`, selected with `Lepus::Saga.configure { |c| c.store = :active_record }`
10
+ * Added: `Lepus::Saga::ActiveRecord::Execution` and `StepAttempt` models (UUIDv7 ids, json context, read scopes) on the `saga_executions` and `saga_step_attempts` tables
11
+ * Added: `rails g lepus:saga:install` (moved from lepus-saga): `failure_reason` is `text` and there is no `lock_version` column
12
+ * Changed: optimistic locking is off, so a legacy `lock_version` column (tables created before the generator) is ignored; `resume_count` is incremented atomically
13
+ * Added: `Execution.prune(finished_before:)` deletes completed executions and their step attempts in batches
14
+ * Changed: the resource index is `(resource_type, resource_id, created_at)`, which serves `executions_for_resource`, and `activerecord >= 8.0` is required
15
+ * Added: the optional query methods of the store contract (`list_executions`, `count_executions`, `execution_series`, `step_latency`, `failure_summary`, `saga_names`) in portable SQL, with time buckets and percentiles computed in Ruby over at most `max_rows` rows; `queries?` is true
16
+ * Added: `lepus:saga:install` indexes `saga_executions (saga_name, created_at)`, `(status, created_at)`, `(finished_at)` and `saga_step_attempts (started_at)`, `(error_class, started_at)`
17
+ * Added: `rails g lepus:saga:dashboard_indexes` adds those indexes to existing tables
18
+ * Added: the `current_step` filter of `list_executions` and `count_executions`
19
+ * Added: the `progress_from`/`progress_to` and `error_class`/`error_step`/`error_from`/`error_to` filters of `list_executions` and `count_executions`, and `execution_count` on `failure_summary` rows
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ core = ENV.fetch("LEPUS_SAGA_PATH", File.expand_path("../lepus-saga", __dir__))
8
+ if File.directory?(core)
9
+ gem "lepus-saga", path: core
10
+ else
11
+ gem "lepus-saga", github: "marcosgz/lepus-saga"
12
+ end
13
+
14
+ gem "sqlite3", ">= 2.1"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Marcos G. Zimmermann
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ <h1 align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/brand/lockup-lepus-saga-on-dark.svg">
4
+ <img alt="Lepus saga" src="docs/brand/lockup-lepus-saga-on-light.svg" width="359">
5
+ </picture>
6
+ </h1>
7
+
8
+ <p align="center"><code>lepus-saga-active_record</code></p>
9
+
10
+ ActiveRecord store for [lepus-saga](https://github.com/marcosgz/lepus-saga). Executions and step attempts live in the `saga_executions` and `saga_step_attempts` tables. Works with PostgreSQL, MySQL/MariaDB and SQLite.
11
+
12
+ ## Installation
13
+
14
+ ```ruby
15
+ gem "lepus-saga"
16
+ gem "lepus-saga-active_record"
17
+ ```
18
+
19
+ ```bash
20
+ bundle install
21
+ bin/rails generate lepus:saga:install
22
+ bin/rails db:migrate
23
+ ```
24
+
25
+ The generator writes one migration that creates both tables. Ids are UUIDv7 strings generated in Ruby, the context columns are `json` and `failure_reason` is `text` (a step's error message can outgrow `VARCHAR(255)`, and MySQL/MariaDB strict mode would reject the write that fails the saga).
26
+
27
+ ```ruby
28
+ # config/initializers/lepus_saga.rb
29
+ Lepus::Saga.configure do |config|
30
+ config.store = :active_record
31
+ end
32
+ ```
33
+
34
+ `:active_record` requires `lepus/saga/stores/active_record` and instantiates `Lepus::Saga::Stores::ActiveRecord`. To use your own subclasses of the models (another table name, `connects_to` another database), pass an instance:
35
+
36
+ ```ruby
37
+ config.store = Lepus::Saga::Stores::ActiveRecord.new(execution_class: Saga::Execution, step_attempt_class: Saga::StepAttempt)
38
+ ```
39
+
40
+ The rest of the setup (definitions, consumers, operations, instrumentation) is in the [lepus-saga README](https://github.com/marcosgz/lepus-saga#readme).
41
+
42
+ ## Models
43
+
44
+ `Lepus::Saga::ActiveRecord::Execution` and `Lepus::Saga::ActiveRecord::StepAttempt` inherit from the abstract `Lepus::Saga::ActiveRecord::Record` and include the core's `ExecutionBehaviour` and `StepAttemptBehaviour`, so they answer `running?`, `terminal?`, `definition_class`, `partition` and the rest.
45
+
46
+ ```ruby
47
+ Lepus::Saga::ActiveRecord::Execution.for_resource("Order", order.id).completed.exists?
48
+ Lepus::Saga::ActiveRecord::Execution.in_progress.where(partition: "organic")
49
+ Lepus::Saga::ActiveRecord::Execution.recent_first.limit(10)
50
+ Lepus::Saga::ActiveRecord::Execution.stalled(15.minutes)
51
+ execution.step_attempts.chronological
52
+ execution.attempts_for_current_step
53
+ Lepus::Saga::ActiveRecord::Execution::STATUSES
54
+ ```
55
+
56
+ Nothing deletes old rows on its own. Prune completed executions (and their step attempts) from a recurring job; call it on a relation to keep some:
57
+
58
+ ```ruby
59
+ Lepus::Saga::ActiveRecord::Execution.prune(finished_before: 30.days.ago)
60
+ Lepus::Saga::ActiveRecord::Execution.where.not(id: pinned_ids).prune(finished_before: 30.days.ago)
61
+ ```
62
+
63
+ ## How it maps the store contract
64
+
65
+ | Store operation | ActiveRecord |
66
+ | --- | --- |
67
+ | `with_lock(execution)` | `execution.with_lock` (reload with `SELECT ... FOR UPDATE` in a transaction) |
68
+ | `with_locked_execution(id)` | `transaction { Execution.lock.find_by(id:) }`, one query |
69
+ | `increment_resume_count` | `update_counters(id, resume_count: 1, touch: true)`, then reads the stored value back |
70
+ | `create_step_attempt` | `create!`; `ActiveRecord::RecordNotUnique` from `idx_saga_step_attempts_dedup` becomes `Lepus::Saga::DuplicateAttemptError` |
71
+ | `stalled_executions(before:)` | `in_progress.where(last_progress_at: ...before).order(:last_progress_at)` |
72
+ | attempt queries | `where(saga_execution_id:, step_name:, direction:, epoch:)` plus `count`/`exists?` |
73
+ | `list_executions` | Filters on the executions table, `order(id: :desc)` with `id < before_id` for keyset paging; the resource search is `LOWER(column) LIKE ? ESCAPE '!'`; the error filters are `id IN (SELECT saga_execution_id FROM saga_step_attempts WHERE status = 'failed' ...)` |
74
+ | `count_executions` | `group(...).count`; grouping by partition also groups by saga name to resolve executions without one |
75
+ | `execution_series` | Plucks `created_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
76
+ | `step_latency` | Joins attempts to executions and plucks the newest `max_rows` + 1 durations; percentiles in Ruby |
77
+ | `failure_summary` | `GROUP BY error_class, saga_name, step_name` with `COUNT(*)` and `COUNT(DISTINCT saga_execution_id)`, ordered by `COUNT(*)` and `MAX(finished_at)`, then one query per group for its latest failure |
78
+ | `saga_names` | `distinct.order(:saga_name).pluck(:saga_name)` |
79
+
80
+ The query methods (the optional part of the contract, used by dashboards) stick to SQL every supported database runs the same way: no `date_trunc`, no `percentile_cont`.
81
+
82
+ On SQLite `FOR UPDATE` is a no-op; Rails 8 opens SQLite transactions as `IMMEDIATE`, which serializes writers on the whole database instead of one row.
83
+
84
+ ### No optimistic locking
85
+
86
+ The engine's concurrency control is the row lock plus the epoch fence: every change to an execution happens under `with_lock`, and the one write made without it, the healer's `resume_count` bump, is a single atomic `UPDATE`. Optimistic locking would add nothing and would turn a legacy `lock_version` column into `ActiveRecord::StaleObjectError` on any write from a stale copy. So `Execution.lock_optimistically = false`, and the generator no longer creates `lock_version`. Tables that already have it keep working: the column has a default and is ignored.
87
+
88
+ The `belongs_to :execution` of a step attempt is optional, because the foreign key already guards it and validating it would load the execution again on every attempt the executor opens under the lock.
89
+
90
+ ## Existing tables
91
+
92
+ Tables created by hand or before the generator (PostgreSQL `uuid` ids with `gen_random_uuid()` defaults, `jsonb` context, a `lock_version` column) work unchanged: the models set UUIDv7 ids before insert, read `jsonb` through the `json` attribute type and ignore `lock_version`.
93
+
94
+ Tables created before the install migration indexed the query columns lack the indexes the query methods filter on. Add them with:
95
+
96
+ ```bash
97
+ bin/rails generate lepus:saga:dashboard_indexes
98
+ bin/rails db:migrate
99
+ ```
100
+
101
+ The migration adds `saga_executions (saga_name, created_at)`, `(status, created_at)` and `(finished_at)`, and `saga_step_attempts (started_at)` and `(error_class, started_at)`, skipping any that already exist.
102
+
103
+ ## Architecture
104
+
105
+ ```
106
+ lepus-saga lepus-saga-active_record
107
+ ────────── ────────────────────────
108
+ Lepus::Saga::Stores::Base <─── Lepus::Saga::Stores::ActiveRecord
109
+ ExecutionBehaviour <─── Lepus::Saga::ActiveRecord::Execution
110
+ StepAttemptBehaviour <─── Lepus::Saga::ActiveRecord::StepAttempt
111
+ "a lepus saga store" ───> spec/lepus/saga/stores/active_record_spec.rb
112
+ "a lepus saga engine" ───> (contract and full engine on every database)
113
+ "a lepus saga query store" ───>
114
+ lib/generators/lepus/saga/install
115
+ lib/generators/lepus/saga/dashboard_indexes
116
+ ```
117
+
118
+ This gem is also the reference for writing another adapter (Sequel, ROM, a key-value store):
119
+
120
+ 1. Name the gem `lepus-saga-<name>` and define `Lepus::Saga::Stores::<Name> < Lepus::Saga::Stores::Base` in `lib/lepus/saga/stores/<name>.rb`, so `config.store = :<name>` finds it.
121
+ 2. Include `Lepus::Saga::ExecutionBehaviour` and `Lepus::Saga::StepAttemptBehaviour` into your record classes.
122
+ 3. Implement each method of `Stores::Base` documented in lepus-saga's `lib/lepus/saga/stores/base.rb`.
123
+ 4. Run the shared examples against every database you support:
124
+
125
+ ```ruby
126
+ require "lepus/saga/rspec"
127
+
128
+ RSpec.describe Lepus::Saga::Stores::Sequel do
129
+ let(:store) { described_class.new }
130
+
131
+ it_behaves_like "a lepus saga store"
132
+ it_behaves_like "a lepus saga engine"
133
+ it_behaves_like "a lepus saga query store" # only if the store implements the optional query methods
134
+ end
135
+ ```
136
+
137
+ The lepus-saga README has a Sequel sketch.
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ bin/setup
143
+ bundle exec rspec # SQLite file in tmp/
144
+ bundle exec rubocop
145
+ ```
146
+
147
+ Against PostgreSQL and MariaDB, start the databases with `docker compose up -d` and run:
148
+
149
+ ```bash
150
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5433/lepus_saga_test BUNDLE_GEMFILE=ci/Gemfile.postgresql bundle exec rspec
151
+ DATABASE_URL=mysql2://root:root@127.0.0.1:3317/lepus_saga_test BUNDLE_GEMFILE=ci/Gemfile.mariadb bundle exec rspec
152
+ ```
153
+
154
+ Examples tagged `:row_locks` (one row locked while another stays free, a single `FOR UPDATE` query) are skipped on SQLite.
155
+
156
+ The Gemfile uses `../lepus-saga` when it exists (or `LEPUS_SAGA_PATH`), and the GitHub repository otherwise.
157
+
158
+ ## License
159
+
160
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,67 @@
1
+ # Lepus brand kit
2
+
3
+ ## The mark
4
+
5
+ A capital L drawn as a single route. The upright is the hare's ear; the foot curls back into a rounded body, and the amber dot inside it is a message in transit. One line, one stop, one message: the mark reads as "L", as a hare, and as a route carrying work.
6
+
7
+ ## Colours
8
+
9
+ | Role | Light background | Dark background |
10
+ |-------|------------------|-----------------|
11
+ | Ink | `#1a2233` | `#e3e9f3` |
12
+ | Amber | `#f2a30f` | `#f2a30f` |
13
+ | Tile (app icons) | `#1a2233` | `#1a2233` |
14
+
15
+ Amber is reserved for the message dot and the rule under "SAGA". Do not recolour the line in amber or put the dot in ink (except in the mono files).
16
+
17
+ ## Clear space and minimum size
18
+
19
+ - Clear space: keep at least the height of the mark's body (the rounded loop, about one third of the mark's height) empty on every side.
20
+ - `mark*.svg`: 48 px and up.
21
+ - `mark-small*.svg` / `favicon.svg`: 16 to 48 px. Its line is heavier and every edge sits on the 16, 32, 48 and 64 px pixel grids, so it stays sharp at favicon sizes. Do not use it large.
22
+ - `lockup-lepus*`: 120 px wide minimum. `lockup-lepus-saga*`: 180 px wide minimum (below that, "SAGA" gets too small; use `lockup-lepus` or the mark alone).
23
+
24
+ ## Which file where
25
+
26
+ | Use | File |
27
+ |-----|------|
28
+ | README header, Lepus | `lockup-lepus-on-light.svg` + `lockup-lepus-on-dark.svg` in a `<picture>` (below) |
29
+ | README header, Lepus saga | `lockup-lepus-saga-on-light.svg` + `lockup-lepus-saga-on-dark.svg` |
30
+ | Web page header that follows the OS theme | `lockup-lepus.svg`, `lockup-lepus-saga.svg`, `mark.svg` (ink switches with `prefers-color-scheme`) |
31
+ | Inline in an app that sets its own text colour | `*-mono.svg` (everything is `currentColor`) |
32
+ | Fixed background you control | `*-on-light.svg` / `*-on-dark.svg` |
33
+ | Browser tab | `favicon.svg` (adaptive), with `favicon.ico` (16/32/48) as fallback |
34
+ | iOS home screen | `apple-touch-icon.png` (180, solid tile) |
35
+ | Web manifest / PWA | `icon-512.png` (solid tile, mark inside the maskable safe zone) |
36
+ | Legacy PNG favicons | `favicon-16.png`, `favicon-32.png` (transparent, light-background ink) |
37
+
38
+ All wordmark text is outlined Overpass (SIL Open Font License), so no font is needed to render it.
39
+
40
+ ### README
41
+
42
+ GitHub follows the viewer's GitHub theme only through `<picture>`, so use the light/dark pair rather than the adaptive file:
43
+
44
+ ```html
45
+ <p align="center">
46
+ <picture>
47
+ <source media="(prefers-color-scheme: dark)" srcset="docs/brand/lockup-lepus-on-dark.svg">
48
+ <img alt="Lepus" src="docs/brand/lockup-lepus-on-light.svg" width="278">
49
+ </picture>
50
+ </p>
51
+ ```
52
+
53
+ For Lepus saga, swap in `lockup-lepus-saga-on-dark.svg` / `lockup-lepus-saga-on-light.svg` and `width="359"`.
54
+
55
+ ### HTML head
56
+
57
+ ```html
58
+ <link rel="icon" href="/favicon.ico" sizes="48x48">
59
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
60
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
61
+ ```
62
+
63
+ ## Don'ts
64
+
65
+ - Don't redraw the wordmark in live text or another typeface.
66
+ - Don't stretch, rotate, outline or add effects to the mark.
67
+ - Don't put the ink mark on mid-tone or busy backgrounds; use the tile or the mono file instead.
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><path class="i" d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="#f2a30f"/></svg>
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 259.67 84.00" width="278" height="90" role="img" aria-label="Lepus"><title>Lepus</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="currentColor"/></g><path fill="currentColor" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 259.67 84.00" width="278" height="90" role="img" aria-label="Lepus"><title>Lepus</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#e3e9f3" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path fill="#e3e9f3" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 259.67 84.00" width="278" height="90" role="img" aria-label="Lepus"><title>Lepus</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#1a2233" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path fill="#1a2233" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 372.21 84.00" width="359" height="81" role="img" aria-label="Lepus saga"><title>Lepus saga</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="currentColor"/></g><path fill="currentColor" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/><path fill="currentColor" d="M272 0.4Q269.87 0.4 268.13 -0.34Q266.4 -1.07 265.17 -2.4Q263.94 -3.73 263.35 -5.5L266.86 -6.8Q267.59 -5.22 268.96 -4.22Q270.33 -3.22 272.09 -3.22Q273.97 -3.22 275.1 -4.06Q276.22 -4.89 276.22 -6.48Q276.22 -7.58 275.55 -8.33Q274.89 -9.07 273.78 -9.59Q272.67 -10.12 271.33 -10.58Q270.08 -11.03 268.81 -11.56Q267.53 -12.1 266.48 -12.87Q265.43 -13.64 264.79 -14.78Q264.15 -15.93 264.15 -17.58Q264.15 -19.24 265.05 -20.63Q265.94 -22.02 267.64 -22.86Q269.33 -23.7 271.73 -23.7Q273.71 -23.7 275.22 -23.05Q276.74 -22.41 277.73 -21.31Q278.71 -20.21 279.1 -18.82L275.64 -17.66Q275.19 -18.69 274.19 -19.39Q273.18 -20.09 271.51 -20.09Q269.87 -20.09 268.96 -19.42Q268.04 -18.75 268.04 -17.54Q268.04 -16.68 268.66 -16.09Q269.27 -15.5 270.31 -15.04Q271.34 -14.59 272.6 -14.14Q273.93 -13.66 275.26 -13.08Q276.58 -12.49 277.68 -11.64Q278.78 -10.79 279.45 -9.55Q280.11 -8.3 280.11 -6.51Q280.11 -4.31 279.04 -2.77Q277.97 -1.23 276.14 -0.41Q274.3 0.4 272 0.4ZM286.33 0 294.99 -23.3H298.62L307.3 0H303.22L301.33 -5.18H292.3L290.45 0H286.33ZM293.53 -8.67H300.07L297.52 -15.7Q297.41 -15.99 297.27 -16.38Q297.14 -16.76 297.01 -17.13Q296.88 -17.51 296.81 -17.8Q296.73 -17.51 296.61 -17.13Q296.48 -16.76 296.35 -16.38Q296.21 -15.99 296.11 -15.7L293.53 -8.67ZM323.19 0.4Q320.53 0.4 318.62 -0.61Q316.71 -1.61 315.5 -3.34Q314.29 -5.06 313.71 -7.21Q313.13 -9.37 313.13 -11.65Q313.13 -13.85 313.74 -15.99Q314.34 -18.12 315.57 -19.87Q316.8 -21.61 318.69 -22.65Q320.59 -23.7 323.19 -23.7Q325.4 -23.7 326.91 -23.03Q328.42 -22.37 329.43 -21.31Q330.45 -20.25 331.19 -19.07L327.79 -17.13Q327.01 -18.3 325.95 -19.17Q324.88 -20.05 323.19 -20.05Q321.46 -20.05 320.29 -19.28Q319.12 -18.51 318.43 -17.27Q317.73 -16.02 317.42 -14.54Q317.11 -13.07 317.11 -11.65Q317.11 -10.04 317.46 -8.54Q317.81 -7.04 318.54 -5.85Q319.26 -4.66 320.42 -3.96Q321.58 -3.27 323.19 -3.27Q324.49 -3.27 325.51 -3.84Q326.52 -4.41 327.1 -5.4Q327.68 -6.39 327.68 -7.64V-8.24H323.64V-11.85H331.66V-8.68Q331.66 -5.74 330.54 -3.71Q329.42 -1.69 327.5 -0.64Q325.58 0.4 323.19 0.4ZM338.01 0 346.68 -23.3H350.3L358.98 0H354.9L353.01 -5.18H343.99L342.13 0H338.01ZM345.21 -8.67H351.76L349.2 -15.7Q349.09 -15.99 348.96 -16.38Q348.82 -16.76 348.69 -17.13Q348.57 -17.51 348.49 -17.8Q348.41 -17.51 348.29 -17.13Q348.17 -16.76 348.03 -16.38Q347.9 -15.99 347.79 -15.7L345.21 -8.67Z"/><rect x="261.75" y="6.00" width="98.46" height="4.5" rx="2.25" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 372.21 84.00" width="359" height="81" role="img" aria-label="Lepus saga"><title>Lepus saga</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#e3e9f3" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path fill="#e3e9f3" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/><path fill="#e3e9f3" d="M272 0.4Q269.87 0.4 268.13 -0.34Q266.4 -1.07 265.17 -2.4Q263.94 -3.73 263.35 -5.5L266.86 -6.8Q267.59 -5.22 268.96 -4.22Q270.33 -3.22 272.09 -3.22Q273.97 -3.22 275.1 -4.06Q276.22 -4.89 276.22 -6.48Q276.22 -7.58 275.55 -8.33Q274.89 -9.07 273.78 -9.59Q272.67 -10.12 271.33 -10.58Q270.08 -11.03 268.81 -11.56Q267.53 -12.1 266.48 -12.87Q265.43 -13.64 264.79 -14.78Q264.15 -15.93 264.15 -17.58Q264.15 -19.24 265.05 -20.63Q265.94 -22.02 267.64 -22.86Q269.33 -23.7 271.73 -23.7Q273.71 -23.7 275.22 -23.05Q276.74 -22.41 277.73 -21.31Q278.71 -20.21 279.1 -18.82L275.64 -17.66Q275.19 -18.69 274.19 -19.39Q273.18 -20.09 271.51 -20.09Q269.87 -20.09 268.96 -19.42Q268.04 -18.75 268.04 -17.54Q268.04 -16.68 268.66 -16.09Q269.27 -15.5 270.31 -15.04Q271.34 -14.59 272.6 -14.14Q273.93 -13.66 275.26 -13.08Q276.58 -12.49 277.68 -11.64Q278.78 -10.79 279.45 -9.55Q280.11 -8.3 280.11 -6.51Q280.11 -4.31 279.04 -2.77Q277.97 -1.23 276.14 -0.41Q274.3 0.4 272 0.4ZM286.33 0 294.99 -23.3H298.62L307.3 0H303.22L301.33 -5.18H292.3L290.45 0H286.33ZM293.53 -8.67H300.07L297.52 -15.7Q297.41 -15.99 297.27 -16.38Q297.14 -16.76 297.01 -17.13Q296.88 -17.51 296.81 -17.8Q296.73 -17.51 296.61 -17.13Q296.48 -16.76 296.35 -16.38Q296.21 -15.99 296.11 -15.7L293.53 -8.67ZM323.19 0.4Q320.53 0.4 318.62 -0.61Q316.71 -1.61 315.5 -3.34Q314.29 -5.06 313.71 -7.21Q313.13 -9.37 313.13 -11.65Q313.13 -13.85 313.74 -15.99Q314.34 -18.12 315.57 -19.87Q316.8 -21.61 318.69 -22.65Q320.59 -23.7 323.19 -23.7Q325.4 -23.7 326.91 -23.03Q328.42 -22.37 329.43 -21.31Q330.45 -20.25 331.19 -19.07L327.79 -17.13Q327.01 -18.3 325.95 -19.17Q324.88 -20.05 323.19 -20.05Q321.46 -20.05 320.29 -19.28Q319.12 -18.51 318.43 -17.27Q317.73 -16.02 317.42 -14.54Q317.11 -13.07 317.11 -11.65Q317.11 -10.04 317.46 -8.54Q317.81 -7.04 318.54 -5.85Q319.26 -4.66 320.42 -3.96Q321.58 -3.27 323.19 -3.27Q324.49 -3.27 325.51 -3.84Q326.52 -4.41 327.1 -5.4Q327.68 -6.39 327.68 -7.64V-8.24H323.64V-11.85H331.66V-8.68Q331.66 -5.74 330.54 -3.71Q329.42 -1.69 327.5 -0.64Q325.58 0.4 323.19 0.4ZM338.01 0 346.68 -23.3H350.3L358.98 0H354.9L353.01 -5.18H343.99L342.13 0H338.01ZM345.21 -8.67H351.76L349.2 -15.7Q349.09 -15.99 348.96 -16.38Q348.82 -16.76 348.69 -17.13Q348.57 -17.51 348.49 -17.8Q348.41 -17.51 348.29 -17.13Q348.17 -16.76 348.03 -16.38Q347.9 -15.99 347.79 -15.7L345.21 -8.67Z"/><rect x="261.75" y="6.00" width="98.46" height="4.5" rx="2.25" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 372.21 84.00" width="359" height="81" role="img" aria-label="Lepus saga"><title>Lepus saga</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#1a2233" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path fill="#1a2233" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/><path fill="#1a2233" d="M272 0.4Q269.87 0.4 268.13 -0.34Q266.4 -1.07 265.17 -2.4Q263.94 -3.73 263.35 -5.5L266.86 -6.8Q267.59 -5.22 268.96 -4.22Q270.33 -3.22 272.09 -3.22Q273.97 -3.22 275.1 -4.06Q276.22 -4.89 276.22 -6.48Q276.22 -7.58 275.55 -8.33Q274.89 -9.07 273.78 -9.59Q272.67 -10.12 271.33 -10.58Q270.08 -11.03 268.81 -11.56Q267.53 -12.1 266.48 -12.87Q265.43 -13.64 264.79 -14.78Q264.15 -15.93 264.15 -17.58Q264.15 -19.24 265.05 -20.63Q265.94 -22.02 267.64 -22.86Q269.33 -23.7 271.73 -23.7Q273.71 -23.7 275.22 -23.05Q276.74 -22.41 277.73 -21.31Q278.71 -20.21 279.1 -18.82L275.64 -17.66Q275.19 -18.69 274.19 -19.39Q273.18 -20.09 271.51 -20.09Q269.87 -20.09 268.96 -19.42Q268.04 -18.75 268.04 -17.54Q268.04 -16.68 268.66 -16.09Q269.27 -15.5 270.31 -15.04Q271.34 -14.59 272.6 -14.14Q273.93 -13.66 275.26 -13.08Q276.58 -12.49 277.68 -11.64Q278.78 -10.79 279.45 -9.55Q280.11 -8.3 280.11 -6.51Q280.11 -4.31 279.04 -2.77Q277.97 -1.23 276.14 -0.41Q274.3 0.4 272 0.4ZM286.33 0 294.99 -23.3H298.62L307.3 0H303.22L301.33 -5.18H292.3L290.45 0H286.33ZM293.53 -8.67H300.07L297.52 -15.7Q297.41 -15.99 297.27 -16.38Q297.14 -16.76 297.01 -17.13Q296.88 -17.51 296.81 -17.8Q296.73 -17.51 296.61 -17.13Q296.48 -16.76 296.35 -16.38Q296.21 -15.99 296.11 -15.7L293.53 -8.67ZM323.19 0.4Q320.53 0.4 318.62 -0.61Q316.71 -1.61 315.5 -3.34Q314.29 -5.06 313.71 -7.21Q313.13 -9.37 313.13 -11.65Q313.13 -13.85 313.74 -15.99Q314.34 -18.12 315.57 -19.87Q316.8 -21.61 318.69 -22.65Q320.59 -23.7 323.19 -23.7Q325.4 -23.7 326.91 -23.03Q328.42 -22.37 329.43 -21.31Q330.45 -20.25 331.19 -19.07L327.79 -17.13Q327.01 -18.3 325.95 -19.17Q324.88 -20.05 323.19 -20.05Q321.46 -20.05 320.29 -19.28Q319.12 -18.51 318.43 -17.27Q317.73 -16.02 317.42 -14.54Q317.11 -13.07 317.11 -11.65Q317.11 -10.04 317.46 -8.54Q317.81 -7.04 318.54 -5.85Q319.26 -4.66 320.42 -3.96Q321.58 -3.27 323.19 -3.27Q324.49 -3.27 325.51 -3.84Q326.52 -4.41 327.1 -5.4Q327.68 -6.39 327.68 -7.64V-8.24H323.64V-11.85H331.66V-8.68Q331.66 -5.74 330.54 -3.71Q329.42 -1.69 327.5 -0.64Q325.58 0.4 323.19 0.4ZM338.01 0 346.68 -23.3H350.3L358.98 0H354.9L353.01 -5.18H343.99L342.13 0H338.01ZM345.21 -8.67H351.76L349.2 -15.7Q349.09 -15.99 348.96 -16.38Q348.82 -16.76 348.69 -17.13Q348.57 -17.51 348.49 -17.8Q348.41 -17.51 348.29 -17.13Q348.17 -16.76 348.03 -16.38Q347.9 -15.99 347.79 -15.7L345.21 -8.67Z"/><rect x="261.75" y="6.00" width="98.46" height="4.5" rx="2.25" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 372.21 84.00" width="359" height="81" role="img" aria-label="Lepus saga"><title>Lepus saga</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><g transform="translate(-13.03 -60.26) scale(1.2414)"><path class="i" d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path class="f" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/><path class="f" d="M272 0.4Q269.87 0.4 268.13 -0.34Q266.4 -1.07 265.17 -2.4Q263.94 -3.73 263.35 -5.5L266.86 -6.8Q267.59 -5.22 268.96 -4.22Q270.33 -3.22 272.09 -3.22Q273.97 -3.22 275.1 -4.06Q276.22 -4.89 276.22 -6.48Q276.22 -7.58 275.55 -8.33Q274.89 -9.07 273.78 -9.59Q272.67 -10.12 271.33 -10.58Q270.08 -11.03 268.81 -11.56Q267.53 -12.1 266.48 -12.87Q265.43 -13.64 264.79 -14.78Q264.15 -15.93 264.15 -17.58Q264.15 -19.24 265.05 -20.63Q265.94 -22.02 267.64 -22.86Q269.33 -23.7 271.73 -23.7Q273.71 -23.7 275.22 -23.05Q276.74 -22.41 277.73 -21.31Q278.71 -20.21 279.1 -18.82L275.64 -17.66Q275.19 -18.69 274.19 -19.39Q273.18 -20.09 271.51 -20.09Q269.87 -20.09 268.96 -19.42Q268.04 -18.75 268.04 -17.54Q268.04 -16.68 268.66 -16.09Q269.27 -15.5 270.31 -15.04Q271.34 -14.59 272.6 -14.14Q273.93 -13.66 275.26 -13.08Q276.58 -12.49 277.68 -11.64Q278.78 -10.79 279.45 -9.55Q280.11 -8.3 280.11 -6.51Q280.11 -4.31 279.04 -2.77Q277.97 -1.23 276.14 -0.41Q274.3 0.4 272 0.4ZM286.33 0 294.99 -23.3H298.62L307.3 0H303.22L301.33 -5.18H292.3L290.45 0H286.33ZM293.53 -8.67H300.07L297.52 -15.7Q297.41 -15.99 297.27 -16.38Q297.14 -16.76 297.01 -17.13Q296.88 -17.51 296.81 -17.8Q296.73 -17.51 296.61 -17.13Q296.48 -16.76 296.35 -16.38Q296.21 -15.99 296.11 -15.7L293.53 -8.67ZM323.19 0.4Q320.53 0.4 318.62 -0.61Q316.71 -1.61 315.5 -3.34Q314.29 -5.06 313.71 -7.21Q313.13 -9.37 313.13 -11.65Q313.13 -13.85 313.74 -15.99Q314.34 -18.12 315.57 -19.87Q316.8 -21.61 318.69 -22.65Q320.59 -23.7 323.19 -23.7Q325.4 -23.7 326.91 -23.03Q328.42 -22.37 329.43 -21.31Q330.45 -20.25 331.19 -19.07L327.79 -17.13Q327.01 -18.3 325.95 -19.17Q324.88 -20.05 323.19 -20.05Q321.46 -20.05 320.29 -19.28Q319.12 -18.51 318.43 -17.27Q317.73 -16.02 317.42 -14.54Q317.11 -13.07 317.11 -11.65Q317.11 -10.04 317.46 -8.54Q317.81 -7.04 318.54 -5.85Q319.26 -4.66 320.42 -3.96Q321.58 -3.27 323.19 -3.27Q324.49 -3.27 325.51 -3.84Q326.52 -4.41 327.1 -5.4Q327.68 -6.39 327.68 -7.64V-8.24H323.64V-11.85H331.66V-8.68Q331.66 -5.74 330.54 -3.71Q329.42 -1.69 327.5 -0.64Q325.58 0.4 323.19 0.4ZM338.01 0 346.68 -23.3H350.3L358.98 0H354.9L353.01 -5.18H343.99L342.13 0H338.01ZM345.21 -8.67H351.76L349.2 -15.7Q349.09 -15.99 348.96 -16.38Q348.82 -16.76 348.69 -17.13Q348.57 -17.51 348.49 -17.8Q348.41 -17.51 348.29 -17.13Q348.17 -16.76 348.03 -16.38Q347.9 -15.99 347.79 -15.7L345.21 -8.67Z"/><rect x="261.75" y="6.00" width="98.46" height="4.5" rx="2.25" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 259.67 84.00" width="278" height="90" role="img" aria-label="Lepus"><title>Lepus</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><g transform="translate(-13.03 -60.26) scale(1.2414)"><path class="i" d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></g><path class="f" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#e3e9f3" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="#1a2233" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke="#e3e9f3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><path d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke="#1a2233" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><path class="i" d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="#f2a30f"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><path class="i" d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="#f2a30f"/></svg>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Lepus
7
+ module Saga
8
+ class DashboardIndexesGenerator < Rails::Generators::Base
9
+ include ::ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds the indexes the store's query methods use to saga tables created before they were in lepus:saga:install"
14
+
15
+ def create_migration_file
16
+ migration_template "add_lepus_saga_dashboard_indexes.rb.erb", File.join(db_migrate_path, "add_lepus_saga_dashboard_indexes.rb")
17
+ end
18
+
19
+ private
20
+
21
+ def migration_version
22
+ "[#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}]"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ class AddLepusSagaDashboardIndexes < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_index :saga_executions, [:saga_name, :created_at], if_not_exists: true
4
+ add_index :saga_executions, [:status, :created_at], if_not_exists: true
5
+ add_index :saga_executions, :finished_at, if_not_exists: true
6
+ add_index :saga_step_attempts, :started_at, if_not_exists: true
7
+ add_index :saga_step_attempts, [:error_class, :started_at], if_not_exists: true
8
+ end
9
+
10
+ def down
11
+ remove_index :saga_step_attempts, [:error_class, :started_at], if_exists: true
12
+ remove_index :saga_step_attempts, :started_at, if_exists: true
13
+ remove_index :saga_executions, :finished_at, if_exists: true
14
+ remove_index :saga_executions, [:status, :created_at], if_exists: true
15
+ remove_index :saga_executions, [:saga_name, :created_at], if_exists: true
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Lepus
7
+ module Saga
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ::ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates the migration for the saga_executions and saga_step_attempts tables"
14
+
15
+ def create_migration_file
16
+ migration_template "create_lepus_saga_tables.rb.erb", File.join(db_migrate_path, "create_lepus_saga_tables.rb")
17
+ end
18
+
19
+ private
20
+
21
+ def migration_version
22
+ "[#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}]"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,52 @@
1
+ class CreateLepusSagaTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :saga_executions, id: :string, limit: 36 do |t|
4
+ t.string :saga_name, null: false
5
+ t.string :resource_type, null: false
6
+ t.string :resource_id, null: false
7
+ t.string :status, null: false, default: "running"
8
+ t.string :current_step
9
+ t.string :direction, null: false, default: "execute"
10
+ t.integer :epoch, null: false, default: 0
11
+ t.json :context, null: false
12
+ t.string :correlation_id
13
+ t.text :failure_reason
14
+ t.string :partition
15
+ t.datetime :started_at, null: false
16
+ t.datetime :finished_at
17
+ t.datetime :last_progress_at, null: false
18
+ t.integer :resume_count, null: false, default: 0
19
+ t.timestamps
20
+ end
21
+ add_index :saga_executions, [:resource_type, :resource_id, :created_at],
22
+ name: "idx_saga_executions_on_resource"
23
+ add_index :saga_executions, [:status, :updated_at]
24
+ add_index :saga_executions, [:status, :last_progress_at],
25
+ name: "index_saga_executions_on_status_and_progress"
26
+ add_index :saga_executions, [:partition, :created_at]
27
+ add_index :saga_executions, [:saga_name, :created_at]
28
+ add_index :saga_executions, [:status, :created_at]
29
+ add_index :saga_executions, :finished_at
30
+
31
+ create_table :saga_step_attempts, id: :string, limit: 36 do |t|
32
+ t.references :saga_execution, type: :string, limit: 36, null: false, foreign_key: true
33
+ t.string :step_name, null: false
34
+ t.string :direction, null: false, default: "execute"
35
+ t.integer :epoch, null: false, default: 0
36
+ t.integer :attempt, null: false
37
+ t.string :status, null: false, default: "running"
38
+ t.string :error_class
39
+ t.text :error_message
40
+ t.text :error_backtrace
41
+ t.json :context_after, null: false
42
+ t.datetime :started_at, null: false
43
+ t.datetime :finished_at
44
+ t.float :duration_ms
45
+ t.timestamps
46
+ end
47
+ add_index :saga_step_attempts, [:saga_execution_id, :step_name, :direction, :epoch, :attempt],
48
+ unique: true, name: "idx_saga_step_attempts_dedup"
49
+ add_index :saga_step_attempts, :started_at
50
+ add_index :saga_step_attempts, [:error_class, :started_at]
51
+ end
52
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module ActiveRecord
6
+ class Execution < Record
7
+ include ExecutionBehaviour
8
+
9
+ self.table_name = "saga_executions"
10
+ # Concurrency is the row lock plus the epoch fence. A legacy
11
+ # lock_version column would otherwise turn every write made outside
12
+ # the lock into a possible StaleObjectError.
13
+ self.lock_optimistically = false
14
+
15
+ attribute :context, :json, default: -> { {} }
16
+ attribute :last_progress_at, :datetime, default: -> { Time.current }
17
+
18
+ has_many :step_attempts,
19
+ class_name: "Lepus::Saga::ActiveRecord::StepAttempt",
20
+ foreign_key: :saga_execution_id,
21
+ inverse_of: :execution,
22
+ dependent: :delete_all
23
+
24
+ before_create { self.id ||= SecureRandom.uuid_v7 }
25
+
26
+ validates :saga_name, :resource_type, :resource_id, presence: true
27
+ validates :status, inclusion: {in: STATUSES}
28
+ validates :direction, inclusion: {in: DIRECTIONS}
29
+
30
+ scope :for_resource, ->(type, id) { where(resource_type: type, resource_id: id) }
31
+ scope :in_progress, -> { where(status: IN_PROGRESS_STATUSES) }
32
+ scope :completed, -> { where(status: "completed") }
33
+ scope :recent_first, -> { order(created_at: :desc) }
34
+ # Deliberately not `updated_at`: a re-publish, a lock or an attempt
35
+ # opened and abandoned all touch the row while the cursor stands
36
+ # still, so a wedged execution would keep resetting its own stall clock.
37
+ scope :stalled, ->(older_than = 15.minutes) { in_progress.where(last_progress_at: ...older_than.ago) }
38
+
39
+ # Deletes completed executions that finished before the cutoff, with
40
+ # their step attempts, batch by batch. Call it on a relation to keep
41
+ # some: `where.not(id: ids_to_keep).prune(finished_before: 30.days.ago)`.
42
+ # Returns the number of executions deleted.
43
+ def self.prune(finished_before:, batch_size: 1_000)
44
+ step_attempt_class = reflect_on_association(:step_attempts).klass
45
+ completed.where(finished_at: ...finished_before).in_batches(of: batch_size).sum do |batch|
46
+ ids = batch.pluck(:id)
47
+ transaction do
48
+ step_attempt_class.where(saga_execution_id: ids).delete_all
49
+ unscoped.where(id: ids).delete_all
50
+ end
51
+ end
52
+ end
53
+
54
+ def attempts_for_current_step
55
+ step_attempts.where(current_step_scope)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module ActiveRecord
6
+ # Abstract parent of the saga models, so an app can point them at another
7
+ # database with `connects_to` without touching its own models.
8
+ class Record < ::ActiveRecord::Base
9
+ self.abstract_class = true
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module ActiveRecord
6
+ class StepAttempt < Record
7
+ include StepAttemptBehaviour
8
+
9
+ self.table_name = "saga_step_attempts"
10
+
11
+ attribute :context_after, :json, default: -> { {} }
12
+
13
+ # The foreign key guards the reference; validating it would load the
14
+ # execution again on every attempt the executor opens under the lock.
15
+ belongs_to :execution,
16
+ class_name: "Lepus::Saga::ActiveRecord::Execution",
17
+ foreign_key: :saga_execution_id,
18
+ inverse_of: :step_attempts,
19
+ optional: true
20
+
21
+ before_create { self.id ||= SecureRandom.uuid_v7 }
22
+
23
+ validates :step_name, presence: true
24
+ validates :status, inclusion: {in: STATUSES}
25
+
26
+ scope :chronological, -> { order(:started_at, :created_at) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module ActiveRecord
6
+ VERSION = "0.3.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/saga"
4
+ require "active_record"
5
+ require_relative "active_record/version"
6
+
7
+ module Lepus
8
+ module Saga
9
+ # ActiveRecord models for the saga tables. Select the store with
10
+ #
11
+ # Lepus::Saga.configure { |config| config.store = :active_record }
12
+ module ActiveRecord
13
+ autoload :Record, "lepus/saga/active_record/record"
14
+ autoload :Execution, "lepus/saga/active_record/execution"
15
+ autoload :StepAttempt, "lepus/saga/active_record/step_attempt"
16
+ end
17
+ end
18
+ end
19
+
20
+ require_relative "stores/active_record"
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/saga/active_record"
4
+
5
+ module Lepus
6
+ module Saga
7
+ module Stores
8
+ # Executions and step attempts as ActiveRecord rows. Locks are
9
+ # SELECT ... FOR UPDATE inside a transaction (a no-op on SQLite, where
10
+ # ActiveRecord 8 opens IMMEDIATE transactions that serialize writers
11
+ # instead). Pass other model
12
+ # classes to use subclasses with their own table names or connections.
13
+ #
14
+ # The query methods use portable SQL only (filters, GROUP BY, COUNT,
15
+ # MAX), so they behave the same on SQLite, PostgreSQL and MySQL/MariaDB:
16
+ # time buckets and percentiles are computed in Ruby over at most
17
+ # `max_rows` plucked values.
18
+ class ActiveRecord < Base
19
+ def initialize(execution_class: nil, step_attempt_class: nil)
20
+ super()
21
+ @execution_class = execution_class
22
+ @step_attempt_class = step_attempt_class
23
+ end
24
+
25
+ def execution_class
26
+ @execution_class ||= Lepus::Saga::ActiveRecord::Execution
27
+ end
28
+
29
+ def step_attempt_class
30
+ @step_attempt_class ||= Lepus::Saga::ActiveRecord::StepAttempt
31
+ end
32
+
33
+ def create_execution(attributes)
34
+ execution_class.create!(attributes)
35
+ end
36
+
37
+ def find_execution(id)
38
+ execution_class.find_by(id: id)
39
+ end
40
+
41
+ def save_execution(execution)
42
+ execution.save!
43
+ execution
44
+ end
45
+
46
+ def with_lock(execution)
47
+ execution.with_lock { yield execution }
48
+ end
49
+
50
+ def with_locked_execution(id)
51
+ execution_class.transaction do
52
+ yield execution_class.lock.find_by(id: id)
53
+ end
54
+ end
55
+
56
+ def increment_resume_count(execution)
57
+ execution_class.update_counters(execution.id, resume_count: 1, touch: true)
58
+ execution.resume_count = execution_class.where(id: execution.id).pick(:resume_count)
59
+ execution.clear_attribute_changes(%i[resume_count])
60
+ execution.resume_count
61
+ end
62
+
63
+ def stalled_executions(before:)
64
+ execution_class.in_progress.where(last_progress_at: ...before).order(:last_progress_at)
65
+ end
66
+
67
+ def executions_for_resource(resource_type, resource_id)
68
+ execution_class.for_resource(resource_type, resource_id).recent_first
69
+ end
70
+
71
+ def create_step_attempt(execution, attributes)
72
+ step_attempt_class.create!(attributes.merge(saga_execution_id: execution.id))
73
+ rescue ::ActiveRecord::RecordNotUnique => error
74
+ raise DuplicateAttemptError, error.message
75
+ end
76
+
77
+ def save_step_attempt(attempt)
78
+ attempt.save!
79
+ attempt
80
+ end
81
+
82
+ def step_attempts(execution)
83
+ attempts_of(execution).chronological
84
+ end
85
+
86
+ def count_step_attempts(execution, step_name:, direction:, epoch:)
87
+ attempts_of(execution).where(step_name: step_name, direction: direction, epoch: epoch).count
88
+ end
89
+
90
+ def running_step_attempt?(execution, step_name:, direction:, epoch:, started_since:)
91
+ attempts_of(execution)
92
+ .where(step_name: step_name, direction: direction, epoch: epoch, status: "running")
93
+ .exists?(started_at: started_since..)
94
+ end
95
+
96
+ def stale_step_attempts(execution, step_name:, direction:, epoch:, started_before:)
97
+ attempts_of(execution)
98
+ .where(step_name: step_name, direction: direction, epoch: epoch, status: "running", started_at: ...started_before)
99
+ end
100
+
101
+ def step_succeeded?(execution, step_name)
102
+ attempts_of(execution).exists?(step_name: step_name, direction: "execute", status: "succeeded")
103
+ end
104
+
105
+ def queries? = true
106
+
107
+ def list_executions(before_id: nil, limit: 50, **filters)
108
+ scope = matching_executions(**filters)
109
+ scope = scope.where(id: ...before_id) if before_id
110
+ scope.order(id: :desc).limit(limit).to_a
111
+ end
112
+
113
+ def count_executions(group_by:, **filters)
114
+ keys = group_keys(group_by)
115
+ columns = keys.include?(:partition) ? keys | [:saga_name] : keys
116
+ counts = matching_executions(**filters).group(*columns).count
117
+ rows = counts.map { |values, count| [columns.zip((columns.size == 1) ? [values] : values).to_h, count] }
118
+ tally_groups(rows, group_by)
119
+ end
120
+
121
+ def execution_series(from:, to:, bucket:, saga_name: nil, max_rows: QUERY_MAX_ROWS)
122
+ seconds = series_bucket(from, to, bucket)
123
+ scope = matching_executions(saga_name: saga_name)
124
+ started, started_truncated = take_rows(
125
+ scope.where(created_at: from...to).order(created_at: :desc).limit(max_rows + 1).pluck(:created_at),
126
+ max_rows
127
+ )
128
+ finished, finished_truncated = take_rows(
129
+ scope.where(status: ExecutionBehaviour::TERMINAL_STATUSES, finished_at: from...to)
130
+ .order(finished_at: :desc).limit(max_rows + 1).pluck(:finished_at, :status),
131
+ max_rows
132
+ )
133
+ {rows: build_series(from, to, seconds, started, finished), truncated: started_truncated || finished_truncated}
134
+ end
135
+
136
+ def step_latency(from:, to:, saga_name: nil, max_rows: QUERY_MAX_ROWS)
137
+ attempts = step_attempt_class.arel_table
138
+ samples, truncated = take_rows(
139
+ attempts_with_saga(from, to, saga_name)
140
+ .where(status: LATENCY_STATUSES).where.not(duration_ms: nil)
141
+ .order(attempts[:started_at].desc).limit(max_rows + 1)
142
+ .pluck(execution_class.arel_table[:saga_name], attempts[:step_name], attempts[:direction], attempts[:duration_ms]),
143
+ max_rows
144
+ )
145
+ {rows: latency_rows(samples), truncated: truncated}
146
+ end
147
+
148
+ def failure_summary(from:, to:, saga_name: nil, limit: 20)
149
+ attempts = step_attempt_class.arel_table
150
+ saga = execution_class.arel_table[:saga_name]
151
+ failed = attempts_with_saga(from, to, saga_name).where(status: "failed")
152
+ failed
153
+ .group(attempts[:error_class], saga, attempts[:step_name])
154
+ .order(Arel::Nodes::Descending.new(Arel.star.count), Arel::Nodes::Descending.new(attempts[:finished_at].maximum))
155
+ .limit(limit)
156
+ .pluck(attempts[:error_class], saga, attempts[:step_name], Arel.star.count, attempts[:saga_execution_id].count(true))
157
+ .map do |error_class, group_saga, step_name, count, execution_count|
158
+ message, last_seen_at, execution_id = failed
159
+ .where(error_class: error_class, step_name: step_name).where(saga.eq(group_saga))
160
+ .order(attempts[:finished_at].desc, attempts[:id].desc)
161
+ .pick(attempts[:error_message], attempts[:finished_at], attempts[:saga_execution_id])
162
+ {
163
+ error_class: error_class, saga_name: group_saga, step_name: step_name, count: count,
164
+ execution_count: execution_count, last_seen_at: last_seen_at, sample_message: message, sample_execution_id: execution_id
165
+ }
166
+ end
167
+ end
168
+
169
+ def saga_names
170
+ execution_class.distinct.order(:saga_name).pluck(:saga_name)
171
+ end
172
+
173
+ private
174
+
175
+ def attempts_of(execution)
176
+ step_attempt_class.where(saga_execution_id: execution.id)
177
+ end
178
+
179
+ def matching_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
180
+ progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil)
181
+ sagas = filter_values(saga_name)
182
+ statuses = filter_values(status)
183
+ steps = filter_values(current_step)
184
+ scope = execution_class.all
185
+ scope = scope.where(saga_name: sagas) if sagas
186
+ scope = scope.where(status: statuses) if statuses
187
+ scope = scope.where(current_step: steps) if steps
188
+ scope = scope.where(created_at: from..) if from
189
+ scope = scope.where(created_at: ...to) if to
190
+ scope = scope.where(last_progress_at: progress_from..) if progress_from
191
+ scope = scope.where(last_progress_at: ...progress_to) if progress_to
192
+ failing = failed_attempts(filter_values(error_class), filter_values(error_step), error_from, error_to)
193
+ scope = scope.where(id: failing.select(:saga_execution_id)) if failing
194
+ scope = scope.and(in_partition(partition.to_s)) if partition.present?
195
+ scope = scope.and(matching_resource(resource.to_s.downcase)) if resource.present?
196
+ scope
197
+ end
198
+
199
+ def failed_attempts(classes, steps, from, to)
200
+ return if [classes, steps, from, to].all?(&:nil?)
201
+
202
+ scope = step_attempt_class.where(status: "failed")
203
+ scope = scope.where(error_class: classes) if classes
204
+ scope = scope.where(step_name: steps) if steps
205
+ scope = scope.where(started_at: from..) if from
206
+ scope = scope.where(started_at: ...to) if to
207
+ scope
208
+ end
209
+
210
+ def in_partition(partition)
211
+ execution_class.where(partition: partition)
212
+ .or(execution_class.where(partition: nil, saga_name: partition_saga_names(partition)))
213
+ end
214
+
215
+ def matching_resource(needle)
216
+ execution_class.where(
217
+ "LOWER(resource_type) LIKE :pattern ESCAPE '!' OR LOWER(resource_id) LIKE :pattern ESCAPE '!'",
218
+ pattern: "%#{execution_class.sanitize_sql_like(needle, "!")}%"
219
+ )
220
+ end
221
+
222
+ # Attempts started in the window, joined to their execution.
223
+ def attempts_with_saga(from, to, saga_name)
224
+ attempts = step_attempt_class.arel_table
225
+ executions = execution_class.arel_table
226
+ scope = step_attempt_class
227
+ .joins(attempts.join(executions).on(executions[:id].eq(attempts[:saga_execution_id])).join_sources)
228
+ .where(started_at: from...to)
229
+ sagas = filter_values(saga_name)
230
+ sagas ? scope.where(executions[:saga_name].in(sagas)) : scope
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.9"
metadata ADDED
@@ -0,0 +1,237 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lepus-saga-active_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Marcos G. Zimmermann
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: lepus-saga
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activejob
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pry
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: railties
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rspec
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rubocop-performance
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rubocop-rspec
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ - !ruby/object:Gem::Dependency
153
+ name: standard
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ description: Keeps lepus-saga executions and step attempts in ActiveRecord tables
167
+ (PostgreSQL, MySQL/MariaDB, SQLite), with a Rails generator for the migration
168
+ email:
169
+ - mgzmaster@gmail.com
170
+ executables: []
171
+ extensions: []
172
+ extra_rdoc_files: []
173
+ files:
174
+ - CHANGELOG.md
175
+ - Gemfile
176
+ - LICENSE.txt
177
+ - README.md
178
+ - Rakefile
179
+ - docs/brand/BRAND.md
180
+ - docs/brand/apple-touch-icon.png
181
+ - docs/brand/favicon-16.png
182
+ - docs/brand/favicon-32.png
183
+ - docs/brand/favicon.ico
184
+ - docs/brand/favicon.svg
185
+ - docs/brand/icon-512.png
186
+ - docs/brand/lockup-lepus-mono.svg
187
+ - docs/brand/lockup-lepus-on-dark.svg
188
+ - docs/brand/lockup-lepus-on-light.svg
189
+ - docs/brand/lockup-lepus-saga-mono.svg
190
+ - docs/brand/lockup-lepus-saga-on-dark.svg
191
+ - docs/brand/lockup-lepus-saga-on-light.svg
192
+ - docs/brand/lockup-lepus-saga.svg
193
+ - docs/brand/lockup-lepus.svg
194
+ - docs/brand/mark-mono.svg
195
+ - docs/brand/mark-on-dark.svg
196
+ - docs/brand/mark-on-light.svg
197
+ - docs/brand/mark-small-mono.svg
198
+ - docs/brand/mark-small-on-dark.svg
199
+ - docs/brand/mark-small-on-light.svg
200
+ - docs/brand/mark-small.svg
201
+ - docs/brand/mark.svg
202
+ - lib/generators/lepus/saga/dashboard_indexes/dashboard_indexes_generator.rb
203
+ - lib/generators/lepus/saga/dashboard_indexes/templates/add_lepus_saga_dashboard_indexes.rb.erb
204
+ - lib/generators/lepus/saga/install/install_generator.rb
205
+ - lib/generators/lepus/saga/install/templates/create_lepus_saga_tables.rb.erb
206
+ - lib/lepus/saga/active_record.rb
207
+ - lib/lepus/saga/active_record/execution.rb
208
+ - lib/lepus/saga/active_record/record.rb
209
+ - lib/lepus/saga/active_record/step_attempt.rb
210
+ - lib/lepus/saga/active_record/version.rb
211
+ - lib/lepus/saga/stores/active_record.rb
212
+ - mise.toml
213
+ homepage: https://github.com/marcosgz/lepus-saga-active_record
214
+ licenses:
215
+ - MIT
216
+ metadata:
217
+ homepage_uri: https://github.com/marcosgz/lepus-saga-active_record
218
+ source_code_uri: https://github.com/marcosgz/lepus-saga-active_record
219
+ changelog_uri: https://github.com/marcosgz/lepus-saga-active_record/blob/master/CHANGELOG.md
220
+ rdoc_options: []
221
+ require_paths:
222
+ - lib
223
+ required_ruby_version: !ruby/object:Gem::Requirement
224
+ requirements:
225
+ - - ">="
226
+ - !ruby/object:Gem::Version
227
+ version: 3.3.0
228
+ required_rubygems_version: !ruby/object:Gem::Requirement
229
+ requirements:
230
+ - - ">="
231
+ - !ruby/object:Gem::Version
232
+ version: '0'
233
+ requirements: []
234
+ rubygems_version: 3.6.9
235
+ specification_version: 4
236
+ summary: ActiveRecord store for Lepus::Saga
237
+ test_files: []