lepus-saga-sequel 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/Gemfile +14 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +173 -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/lepus/saga/sequel/execution.rb +72 -0
  31. data/lib/lepus/saga/sequel/migrations/001_create_lepus_saga_tables.rb +62 -0
  32. data/lib/lepus/saga/sequel/migrations/002_add_lepus_saga_dashboard_indexes.rb +31 -0
  33. data/lib/lepus/saga/sequel/record.rb +34 -0
  34. data/lib/lepus/saga/sequel/step_attempt.rb +35 -0
  35. data/lib/lepus/saga/sequel/version.rb +9 -0
  36. data/lib/lepus/saga/sequel.rb +45 -0
  37. data/lib/lepus/saga/stores/sequel.rb +266 -0
  38. data/mise.toml +2 -0
  39. metadata +207 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 688935ea658850abc331020a8bef9b9a11a940c99e19a65c054fb593871406b9
4
+ data.tar.gz: 69feb77e326f97a3a344c29b79f6ebb80f254906239a7d3ff99a70bbd38df562
5
+ SHA512:
6
+ metadata.gz: 36b993b968e9781e5eb1cedc84bb52fa2329bfa5b48f6e81b9f941cf8a6b3b46890108eaa68d758bd70451101c69c82969f03cdf52e51622d02292acc5eeacf1
7
+ data.tar.gz: df9f85d83bdc581a1db3da45616a19e2c8b2f4fedcd636bff5322aae401f8505f062811fb71b58b84e6498f842754b0100af1720a0d312adaaa804dd15dced45
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
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::Sequel`, selected with `Lepus::Saga.configure { |c| c.store = :sequel }`, and `Lepus::Saga::Sequel.db=` to bind the models to a database other than `Sequel::Model.db`
10
+ * Added: `Lepus::Saga::Sequel::Execution` and `StepAttempt` models (UUIDv7 ids, json context, read datasets) on the `saga_executions` and `saga_step_attempts` tables
11
+ * Added: `Lepus::Saga::Sequel.migrate(db)` runs the bundled migrations, tracked in `lepus_saga_schema_info`; the schema and index names match the lepus-saga-active_record install migration, `failure_reason` is `text` and there is no `lock_version` column
12
+ * Added: migration 002 adds the dashboard indexes to tables created without them
13
+ * Added: `Execution.prune(finished_before:)` deletes completed executions and their step attempts in batches
14
+ * 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
15
+ * Added: SQLite locks open `IMMEDIATE` transactions, since SQLite has no `FOR UPDATE`
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,173 @@
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-sequel</code></p>
9
+
10
+ [Sequel](https://sequel.jeremyevans.net) 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-sequel"
17
+ ```
18
+
19
+ The gem ships its migrations. Run them with:
20
+
21
+ ```ruby
22
+ require "lepus/saga/sequel"
23
+
24
+ Lepus::Saga::Sequel.migrate(DB) # create the tables
25
+ Lepus::Saga::Sequel.migrate(DB, target: 0) # drop them
26
+ ```
27
+
28
+ The version is tracked in its own `lepus_saga_schema_info` table, so it never collides with your app's `schema_info` or `schema_migrations`. To keep everything in one migration history instead, copy the files from `lib/lepus/saga/sequel/migrations` into your own migrations directory.
29
+
30
+ 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). The table, column and index names are the ones the lepus-saga-active_record generator creates, so an app can move between the two gems on the same tables. Column types can differ slightly: Sequel creates unsized strings as `text` on PostgreSQL and `duration_ms` as a double-precision float on MySQL/MariaDB.
31
+
32
+ ## Configuration
33
+
34
+ ```ruby
35
+ Sequel.database_timezone = :utc
36
+ DB = Sequel.connect(ENV["DATABASE_URL"])
37
+
38
+ Lepus::Saga.configure do |config|
39
+ config.store = :sequel
40
+ end
41
+ ```
42
+
43
+ `:sequel` requires `lepus/saga/stores/sequel` and instantiates `Lepus::Saga::Stores::Sequel`. The models use `Sequel::Model.db` (the first database you connected to). To keep the saga tables in another database, set it before the models are first used:
44
+
45
+ ```ruby
46
+ Lepus::Saga::Sequel.db = SAGA_DB
47
+ ```
48
+
49
+ To use your own subclasses of the models (another table name, extra plugins), redeclare the associations so `step_attempts`, `attempts_for_current_step` and `prune` use your attempt class, and pass an instance:
50
+
51
+ ```ruby
52
+ class SagaExecution < Lepus::Saga::Sequel::Execution
53
+ set_dataset :order_saga_executions
54
+ one_to_many :step_attempts, class: "SagaStepAttempt", key: :saga_execution_id
55
+ end
56
+
57
+ class SagaStepAttempt < Lepus::Saga::Sequel::StepAttempt
58
+ set_dataset :order_saga_step_attempts
59
+ many_to_one :execution, class: "SagaExecution", key: :saga_execution_id
60
+ end
61
+
62
+ config.store = Lepus::Saga::Stores::Sequel.new(execution_class: SagaExecution, step_attempt_class: SagaStepAttempt)
63
+ ```
64
+
65
+ The rest of the setup (definitions, consumers, operations, instrumentation) is in the [lepus-saga README](https://github.com/marcosgz/lepus-saga#readme).
66
+
67
+ ### Requirements
68
+
69
+ - **Times in UTC.** The engine writes `Time.now.utc` and compares it with stored times to find stalled executions. Sequel stores times in local time by default, which mixes the two in a `timestamp`/`datetime` column. Set `Sequel.database_timezone = :utc` (or `DB.timezone = :utc` for one database).
70
+ - **Fractional seconds on MySQL/MariaDB.** Open the database with `fractional_seconds: true` (or `?fractional_seconds=true` in the URL) *before* running the migrations. Without it Sequel creates `datetime` columns without microseconds and truncates the times it writes, so executions created in the same second sort arbitrarily.
71
+ - **A busy handler on SQLite with threads.** SQLite serializes writers on the whole database. Wait for the lock with `busy_handler_timeout`, which lets other Ruby threads run while it waits (`busy_timeout`, which Sequel's `:timeout` option sets, does not):
72
+
73
+ ```ruby
74
+ DB = Sequel.sqlite("db/app.sqlite3", after_connect: ->(connection) { connection.busy_handler_timeout = 10_000 })
75
+ ```
76
+
77
+ ## Models
78
+
79
+ `Lepus::Saga::Sequel::Execution` and `Lepus::Saga::Sequel::StepAttempt` are `Sequel::Model` classes that include the core's `ExecutionBehaviour` and `StepAttemptBehaviour`, so they answer `running?`, `terminal?`, `definition_class`, `partition` and the rest.
80
+
81
+ ```ruby
82
+ Lepus::Saga::Sequel::Execution.for_resource("Order", order.id).completed.empty?
83
+ Lepus::Saga::Sequel::Execution.in_progress.where(partition: "priority")
84
+ Lepus::Saga::Sequel::Execution.recent_first.limit(10)
85
+ Lepus::Saga::Sequel::Execution.stalled(15 * 60)
86
+ execution.step_attempts_dataset.chronological
87
+ execution.attempts_for_current_step
88
+ Lepus::Saga::Sequel::Execution::STATUSES
89
+ ```
90
+
91
+ Defaults (status, direction, epoch, resume count, context, progress clock) are set in `before_validation`, and the JSON columns are serialized there too, so always save through `save`, never with `validate: false`. Saves raise `Sequel::ValidationFailed` on invalid records whatever `raise_on_save_failure` says elsewhere in the app.
92
+
93
+ Nothing deletes old rows on its own. Prune completed executions (and their step attempts) from a recurring job; call it on a filtered dataset to keep some:
94
+
95
+ ```ruby
96
+ Lepus::Saga::Sequel::Execution.prune(finished_before: Time.now.utc - 30 * 86_400)
97
+ Lepus::Saga::Sequel::Execution.exclude(id: pinned_ids).prune(finished_before: Time.now.utc - 30 * 86_400)
98
+ ```
99
+
100
+ ## How it maps the store contract
101
+
102
+ | Store operation | Sequel |
103
+ | --- | --- |
104
+ | `with_lock(execution)` | `transaction { execution.lock! }` (reload in place with `SELECT ... FOR UPDATE`) |
105
+ | `with_locked_execution(id)` | `transaction { Execution.for_update.first(id:) }`, one query |
106
+ | `save_execution`, `save_step_attempt` | `save`, which writes every column, so a context changed in place is persisted |
107
+ | `increment_resume_count` | `UPDATE ... SET resume_count = resume_count + 1, updated_at = ?`, then reads the stored value back |
108
+ | `create_step_attempt` | `create`; `Sequel::UniqueConstraintViolation` from `idx_saga_step_attempts_dedup` becomes `Lepus::Saga::DuplicateAttemptError` |
109
+ | `stalled_executions(before:)` | `in_progress.where(last_progress_at < before).order(:last_progress_at).all` |
110
+ | attempt queries | `where(saga_execution_id:, step_name:, direction:, epoch:)` plus `count`/`empty?` |
111
+ | `list_executions` | Filters on the executions table, `order(Sequel.desc(:id))` with `id < before_id` for keyset paging; the resource search is `ILIKE` with the LIKE wildcards escaped; the error filters are `id IN (SELECT saga_execution_id FROM saga_step_attempts WHERE status = 'failed' ...)` |
112
+ | `count_executions` | `group_and_count(...)`; grouping by partition also groups by saga name to resolve executions without one |
113
+ | `execution_series` | Selects `created_at`, and `finished_at` and `status` of terminal executions, newest first and at most `max_rows` + 1; buckets them in Ruby |
114
+ | `step_latency` | Joins attempts to executions and selects the newest `max_rows` + 1 durations; percentiles in Ruby |
115
+ | `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 |
116
+ | `saga_names` | `distinct.order(:saga_name).select_map(:saga_name)` |
117
+
118
+ 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`.
119
+
120
+ The methods that return several records return Arrays: the engine runs other queries while it walks them, which Sequel's streaming `each` does not allow.
121
+
122
+ On SQLite, which has no `FOR UPDATE`, the store opens its lock transactions as `IMMEDIATE`: the write lock is taken up front, so a second locker waits instead of reading alongside, and writers are serialized on the whole database instead of one row.
123
+
124
+ ### No optimistic locking
125
+
126
+ 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, so the models do not load Sequel's `optimistic_locking` plugin and the migration creates no `lock_version` column.
127
+
128
+ The `many_to_one :execution` of a step attempt is not validated, because the foreign key already guards it and validating it would load the execution again on every attempt the executor opens under the lock.
129
+
130
+ ## Existing tables
131
+
132
+ The first migration uses `create_table?`, so it adopts tables that already exist (created by hand or by the lepus-saga-active_record generator). Note that rolling it back drops them.
133
+
134
+ Tables created before the indexes the query methods filter on existed lack them. The second 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.
135
+
136
+ Tables created by hand (PostgreSQL `uuid` ids, `jsonb` context) work too: the models set UUIDv7 ids before insert and read the JSON columns whether the driver returns a String or a parsed value (the `pg_json` extension).
137
+
138
+ ## Architecture
139
+
140
+ ```
141
+ lepus-saga lepus-saga-sequel
142
+ ────────── ─────────────────
143
+ Lepus::Saga::Stores::Base <─── Lepus::Saga::Stores::Sequel
144
+ ExecutionBehaviour <─── Lepus::Saga::Sequel::Execution
145
+ StepAttemptBehaviour <─── Lepus::Saga::Sequel::StepAttempt
146
+ "a lepus saga store" ───> spec/lepus/saga/stores/sequel_spec.rb
147
+ "a lepus saga engine" ───> (contract and full engine on every database)
148
+ "a lepus saga query store" ───>
149
+ lib/lepus/saga/sequel/migrations
150
+ ```
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ bin/setup
156
+ bundle exec rspec # SQLite file in tmp/
157
+ bundle exec rubocop
158
+ ```
159
+
160
+ Against PostgreSQL and MariaDB, start the databases with `docker compose up -d` and run:
161
+
162
+ ```bash
163
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5433/lepus_saga_test BUNDLE_GEMFILE=ci/Gemfile.postgresql bundle exec rspec
164
+ DATABASE_URL="mysql2://root:root@127.0.0.1:3317/lepus_saga_test?fractional_seconds=true" BUNDLE_GEMFILE=ci/Gemfile.mariadb bundle exec rspec
165
+ ```
166
+
167
+ Examples tagged `:row_locks` (one row locked while another stays free, a single `FOR UPDATE` query) are skipped on SQLite.
168
+
169
+ The Gemfile uses `../lepus-saga` when it exists (or `LEPUS_SAGA_PATH`), and the GitHub repository otherwise.
170
+
171
+ ## License
172
+
173
+ 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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Sequel
6
+ class Execution < ::Sequel::Model(Lepus::Saga::Sequel.db[:saga_executions])
7
+ include ExecutionBehaviour
8
+
9
+ plugin Record, :context
10
+ plugin :association_dependencies
11
+
12
+ one_to_many :step_attempts, class: "Lepus::Saga::Sequel::StepAttempt", key: :saga_execution_id
13
+ add_association_dependencies step_attempts: :delete
14
+
15
+ dataset_module do
16
+ where :in_progress, status: IN_PROGRESS_STATUSES
17
+ where :completed, status: "completed"
18
+ order :recent_first, ::Sequel.desc(:created_at)
19
+
20
+ def for_resource(type, id)
21
+ where(resource_type: type, resource_id: id.to_s)
22
+ end
23
+
24
+ # Deliberately not `updated_at`: a re-publish, a lock or an attempt
25
+ # opened and abandoned all touch the row while the cursor stands
26
+ # still, so a wedged execution would keep resetting its own stall clock.
27
+ def stalled(older_than = 15 * 60)
28
+ in_progress.where(::Sequel[:last_progress_at] < Time.now.utc - older_than)
29
+ end
30
+
31
+ # Deletes completed executions that finished before the cutoff, with
32
+ # their step attempts, batch by batch. Call it on a filtered dataset
33
+ # to keep some: `exclude(id: ids_to_keep).prune(finished_before:)`.
34
+ # Returns the number of executions deleted.
35
+ def prune(finished_before:, batch_size: 1_000)
36
+ step_attempt_class = model.association_reflection(:step_attempts).associated_class
37
+ expired = completed.where(::Sequel[:finished_at] < finished_before)
38
+ deleted = 0
39
+ until (ids = expired.limit(batch_size).select_map(:id)).empty?
40
+ db.transaction do
41
+ step_attempt_class.where(saga_execution_id: ids).delete
42
+ deleted += model.where(id: ids).delete
43
+ end
44
+ end
45
+ deleted
46
+ end
47
+ end
48
+
49
+ def attempts_for_current_step
50
+ step_attempts_dataset.where(current_step_scope)
51
+ end
52
+
53
+ def validate
54
+ super
55
+ validates_presence %i[saga_name resource_type resource_id]
56
+ validates_includes STATUSES, :status
57
+ validates_includes DIRECTIONS, :direction
58
+ end
59
+
60
+ def before_validation
61
+ self.status ||= "running"
62
+ self.direction ||= "execute"
63
+ self.epoch ||= 0
64
+ self.resume_count ||= 0
65
+ self.context = {} if context.nil?
66
+ self.last_progress_at ||= Time.now.utc
67
+ super
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table?(:saga_executions) do
6
+ String :id, size: 36, primary_key: true
7
+ String :saga_name, null: false
8
+ String :resource_type, null: false
9
+ String :resource_id, null: false
10
+ String :status, null: false, default: "running"
11
+ String :current_step
12
+ String :direction, null: false, default: "execute"
13
+ Integer :epoch, null: false, default: 0
14
+ json :context, null: false
15
+ String :correlation_id
16
+ String :failure_reason, text: true
17
+ String :partition
18
+ DateTime :started_at, null: false
19
+ DateTime :finished_at
20
+ DateTime :last_progress_at, null: false
21
+ Integer :resume_count, null: false, default: 0
22
+ DateTime :created_at, null: false
23
+ DateTime :updated_at, null: false
24
+
25
+ index %i[resource_type resource_id created_at], name: :idx_saga_executions_on_resource
26
+ index %i[status updated_at], name: :index_saga_executions_on_status_and_updated_at
27
+ index %i[status last_progress_at], name: :index_saga_executions_on_status_and_progress
28
+ index %i[partition created_at], name: :index_saga_executions_on_partition_and_created_at
29
+ index %i[saga_name created_at], name: :index_saga_executions_on_saga_name_and_created_at
30
+ index %i[status created_at], name: :index_saga_executions_on_status_and_created_at
31
+ index :finished_at, name: :index_saga_executions_on_finished_at
32
+ end
33
+
34
+ create_table?(:saga_step_attempts) do
35
+ String :id, size: 36, primary_key: true
36
+ foreign_key :saga_execution_id, :saga_executions, type: String, size: 36, null: false
37
+ String :step_name, null: false
38
+ String :direction, null: false, default: "execute"
39
+ Integer :epoch, null: false, default: 0
40
+ Integer :attempt, null: false
41
+ String :status, null: false, default: "running"
42
+ String :error_class
43
+ String :error_message, text: true
44
+ String :error_backtrace, text: true
45
+ json :context_after, null: false
46
+ DateTime :started_at, null: false
47
+ DateTime :finished_at
48
+ Float :duration_ms
49
+ DateTime :created_at, null: false
50
+ DateTime :updated_at, null: false
51
+
52
+ index :saga_execution_id, name: :index_saga_step_attempts_on_saga_execution_id
53
+ index %i[saga_execution_id step_name direction epoch attempt], unique: true, name: :idx_saga_step_attempts_dedup
54
+ index :started_at, name: :index_saga_step_attempts_on_started_at
55
+ index %i[error_class started_at], name: :index_saga_step_attempts_on_error_class_and_started_at
56
+ end
57
+ end
58
+
59
+ down do
60
+ drop_table?(:saga_step_attempts, :saga_executions)
61
+ end
62
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The indexes the query methods filter on, for tables created before 001
4
+ # declared them. A no-op on tables created by 001.
5
+ Sequel.migration do
6
+ dashboard_indexes = {
7
+ saga_executions: {
8
+ index_saga_executions_on_saga_name_and_created_at: %i[saga_name created_at],
9
+ index_saga_executions_on_status_and_created_at: %i[status created_at],
10
+ index_saga_executions_on_finished_at: %i[finished_at]
11
+ },
12
+ saga_step_attempts: {
13
+ index_saga_step_attempts_on_started_at: %i[started_at],
14
+ index_saga_step_attempts_on_error_class_and_started_at: %i[error_class started_at]
15
+ }
16
+ }
17
+
18
+ up do
19
+ dashboard_indexes.each do |table, named|
20
+ present = indexes(table).keys
21
+ named.each { |name, columns| add_index(table, columns, name: name) unless present.include?(name) }
22
+ end
23
+ end
24
+
25
+ down do
26
+ dashboard_indexes.each do |table, named|
27
+ present = indexes(table).keys
28
+ named.each { |name, columns| drop_index(table, columns, name: name) if present.include?(name) }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Sequel
6
+ # Sequel plugin shared by the saga models: UUIDv7 ids generated in Ruby,
7
+ # created_at/updated_at, validations and a JSON column read back as a
8
+ # Hash whether the driver returns a String or an already parsed value.
9
+ module Record
10
+ JSON_COLUMN = [
11
+ ->(value) { JSON.generate(value) },
12
+ ->(value) { value.is_a?(String) ? JSON.parse(value) : value.to_h }
13
+ ].freeze
14
+
15
+ def self.apply(model, json_column)
16
+ model.plugin :serialization, JSON_COLUMN, json_column
17
+ model.plugin :timestamps, update_on_create: true
18
+ model.plugin :validation_helpers
19
+ end
20
+
21
+ def self.configure(model, _json_column)
22
+ model.raise_on_save_failure = true
23
+ end
24
+
25
+ module InstanceMethods
26
+ def before_create
27
+ self.id ||= SecureRandom.uuid_v7
28
+ super
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Sequel
6
+ class StepAttempt < ::Sequel::Model(Lepus::Saga::Sequel.db[:saga_step_attempts])
7
+ include StepAttemptBehaviour
8
+
9
+ plugin Record, :context_after
10
+
11
+ # The foreign key guards the reference; validating it would load the
12
+ # execution again on every attempt the executor opens under the lock.
13
+ many_to_one :execution, class: "Lepus::Saga::Sequel::Execution", key: :saga_execution_id
14
+
15
+ dataset_module do
16
+ order :chronological, :started_at, :created_at
17
+ end
18
+
19
+ def validate
20
+ super
21
+ validates_presence :step_name
22
+ validates_includes STATUSES, :status
23
+ end
24
+
25
+ def before_validation
26
+ self.status ||= "running"
27
+ self.direction ||= "execute"
28
+ self.epoch ||= 0
29
+ self.context_after = {} if context_after.nil?
30
+ super
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Sequel
6
+ VERSION = "0.3.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "lepus/saga"
6
+ require "sequel"
7
+ require_relative "sequel/version"
8
+
9
+ module Lepus
10
+ module Saga
11
+ # Sequel models for the saga tables. Select the store with
12
+ #
13
+ # Lepus::Saga.configure { |config| config.store = :sequel }
14
+ #
15
+ # The models bind to `Lepus::Saga::Sequel.db` (Sequel::Model.db unless
16
+ # set) the first time they are used, so set it and run the migrations
17
+ # before that.
18
+ module Sequel
19
+ MIGRATIONS_PATH = File.expand_path("sequel/migrations", __dir__)
20
+ MIGRATIONS_TABLE = :lepus_saga_schema_info
21
+
22
+ autoload :Record, "lepus/saga/sequel/record"
23
+ autoload :Execution, "lepus/saga/sequel/execution"
24
+ autoload :StepAttempt, "lepus/saga/sequel/step_attempt"
25
+
26
+ class << self
27
+ attr_writer :db
28
+
29
+ def db
30
+ @db || ::Sequel::Model.db
31
+ end
32
+
33
+ # Creates (or rolls back, with `target:`) the saga tables, tracking the
34
+ # version in its own table so it never collides with the app's
35
+ # migrations.
36
+ def migrate(db = self.db, **options)
37
+ ::Sequel.extension :migration
38
+ ::Sequel::Migrator.run(db, MIGRATIONS_PATH, table: MIGRATIONS_TABLE, **options)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "stores/sequel"
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/saga/sequel"
4
+
5
+ module Lepus
6
+ module Saga
7
+ module Stores
8
+ # Executions and step attempts as Sequel models. Locks are
9
+ # SELECT ... FOR UPDATE inside a transaction; on SQLite, where FOR UPDATE
10
+ # does not exist, transactions open as IMMEDIATE, which serializes
11
+ # writers on the whole database instead. Pass other model classes to use
12
+ # subclasses with their own datasets.
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` selected values.
18
+ class Sequel < 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::Sequel::Execution
27
+ end
28
+
29
+ def step_attempt_class
30
+ @step_attempt_class ||= Lepus::Saga::Sequel::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[id]
39
+ end
40
+
41
+ def save_execution(execution)
42
+ execution.save
43
+ execution
44
+ end
45
+
46
+ def with_lock(execution)
47
+ transaction do
48
+ execution.lock!
49
+ yield execution
50
+ end
51
+ end
52
+
53
+ def with_locked_execution(id)
54
+ transaction { yield execution_class.for_update.first(id: id) }
55
+ end
56
+
57
+ def increment_resume_count(execution)
58
+ dataset = execution_class.where(id: execution.id)
59
+ dataset.update(resume_count: ::Sequel[:resume_count] + 1, updated_at: Time.now.utc)
60
+ execution.values[:resume_count] = dataset.get(:resume_count)
61
+ end
62
+
63
+ def stalled_executions(before:)
64
+ execution_class.in_progress.where(::Sequel[:last_progress_at] < before).order(:last_progress_at).all
65
+ end
66
+
67
+ def executions_for_resource(resource_type, resource_id)
68
+ execution_class.for_resource(resource_type, resource_id).recent_first.all
69
+ end
70
+
71
+ def create_step_attempt(execution, attributes)
72
+ step_attempt_class.create(attributes.merge(saga_execution_id: execution.id))
73
+ rescue ::Sequel::UniqueConstraintViolation => 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.all
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
+ .where(::Sequel[:started_at] >= started_since)
94
+ .empty?
95
+ end
96
+
97
+ def stale_step_attempts(execution, step_name:, direction:, epoch:, started_before:)
98
+ attempts_of(execution)
99
+ .where(step_name: step_name, direction: direction, epoch: epoch, status: "running")
100
+ .where(::Sequel[:started_at] < started_before)
101
+ .all
102
+ end
103
+
104
+ def step_succeeded?(execution, step_name)
105
+ !attempts_of(execution).where(step_name: step_name, direction: "execute", status: "succeeded").empty?
106
+ end
107
+
108
+ def queries? = true
109
+
110
+ def list_executions(before_id: nil, limit: 50, **filters)
111
+ scope = matching_executions(**filters)
112
+ scope = scope.where(::Sequel[:id] < before_id) if before_id
113
+ scope.order(::Sequel.desc(:id)).limit(limit).all
114
+ end
115
+
116
+ def count_executions(group_by:, **filters)
117
+ keys = group_keys(group_by)
118
+ columns = keys.include?(:partition) ? keys | [:saga_name] : keys
119
+ rows = matching_executions(**filters).group_and_count(*columns).naked.all
120
+ tally_groups(rows.map { |row| [row.slice(*columns), row[:count]] }, group_by)
121
+ end
122
+
123
+ def execution_series(from:, to:, bucket:, saga_name: nil, max_rows: QUERY_MAX_ROWS)
124
+ seconds = series_bucket(from, to, bucket)
125
+ scope = matching_executions(saga_name: saga_name)
126
+ started, started_truncated = take_rows(
127
+ in_window(scope, :created_at, from, to)
128
+ .order(::Sequel.desc(:created_at)).limit(max_rows + 1).select_map(:created_at),
129
+ max_rows
130
+ )
131
+ finished, finished_truncated = take_rows(
132
+ in_window(scope.where(status: ExecutionBehaviour::TERMINAL_STATUSES), :finished_at, from, to)
133
+ .order(::Sequel.desc(:finished_at)).limit(max_rows + 1).select_map(%i[finished_at status]),
134
+ max_rows
135
+ )
136
+ {rows: build_series(from, to, seconds, started, finished), truncated: started_truncated || finished_truncated}
137
+ end
138
+
139
+ def step_latency(from:, to:, saga_name: nil, max_rows: QUERY_MAX_ROWS)
140
+ samples, truncated = take_rows(
141
+ attempts_with_saga(from, to, saga_name)
142
+ .where(attempts_table[:status] => LATENCY_STATUSES)
143
+ .exclude(attempts_table[:duration_ms] => nil)
144
+ .order(::Sequel.desc(attempts_table[:started_at])).limit(max_rows + 1)
145
+ .select_map([executions_table[:saga_name], attempts_table[:step_name], attempts_table[:direction], attempts_table[:duration_ms]]),
146
+ max_rows
147
+ )
148
+ {rows: latency_rows(samples), truncated: truncated}
149
+ end
150
+
151
+ def failure_summary(from:, to:, saga_name: nil, limit: 20)
152
+ attempts = attempts_table
153
+ saga = executions_table[:saga_name]
154
+ count = ::Sequel.function(:count).*
155
+ failed = attempts_with_saga(from, to, saga_name).where(attempts[:status] => "failed")
156
+ failed
157
+ .group(attempts[:error_class], saga, attempts[:step_name])
158
+ .order(::Sequel.desc(count), ::Sequel.desc(::Sequel.function(:max, attempts[:finished_at])))
159
+ .limit(limit)
160
+ .select_map([
161
+ attempts[:error_class], saga, attempts[:step_name], count.as(:count),
162
+ ::Sequel.function(:count, attempts[:saga_execution_id]).distinct.as(:execution_count)
163
+ ])
164
+ .map do |error_class, group_saga, step_name, group_count, execution_count|
165
+ message, last_seen_at, execution_id = failed
166
+ .where(attempts[:error_class] => error_class, attempts[:step_name] => step_name, saga => group_saga)
167
+ .order(::Sequel.desc(attempts[:finished_at]), ::Sequel.desc(attempts[:id]))
168
+ .get([attempts[:error_message], attempts[:finished_at], attempts[:saga_execution_id]])
169
+ {
170
+ error_class: error_class, saga_name: group_saga, step_name: step_name, count: group_count.to_i,
171
+ execution_count: execution_count.to_i, last_seen_at: last_seen_at, sample_message: message, sample_execution_id: execution_id
172
+ }
173
+ end
174
+ end
175
+
176
+ def saga_names
177
+ execution_class.distinct.order(:saga_name).select_map(:saga_name)
178
+ end
179
+
180
+ private
181
+
182
+ def db = execution_class.db
183
+
184
+ # SQLite has no FOR UPDATE: an IMMEDIATE transaction takes the write
185
+ # lock up front, so a second locker waits instead of reading alongside.
186
+ # Sequel's SQLite adapter wraps an ArgumentError raised in the block in
187
+ # Sequel::DatabaseError; the caller gets its own error back.
188
+ def transaction(&)
189
+ return db.transaction(&) unless db.database_type == :sqlite
190
+
191
+ begin
192
+ db.transaction(mode: :immediate, &)
193
+ rescue ::Sequel::DatabaseError => error
194
+ raise error unless error.wrapped_exception.is_a?(ArgumentError)
195
+
196
+ raise error.wrapped_exception, cause: nil
197
+ end
198
+ end
199
+
200
+ def attempts_of(execution)
201
+ step_attempt_class.where(saga_execution_id: execution.id)
202
+ end
203
+
204
+ def executions_table = ::Sequel[execution_class.table_name]
205
+
206
+ def attempts_table = ::Sequel[step_attempt_class.table_name]
207
+
208
+ def in_window(scope, column, from, to)
209
+ scope = scope.where(::Sequel[column] >= from) if from
210
+ scope = scope.where(::Sequel[column] < to) if to
211
+ scope
212
+ end
213
+
214
+ def matching_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
215
+ progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil)
216
+ sagas = filter_values(saga_name)
217
+ statuses = filter_values(status)
218
+ steps = filter_values(current_step)
219
+ scope = execution_class.dataset
220
+ scope = scope.where(saga_name: sagas) if sagas
221
+ scope = scope.where(status: statuses) if statuses
222
+ scope = scope.where(current_step: steps) if steps
223
+ scope = in_window(scope, :created_at, from, to)
224
+ scope = in_window(scope, :last_progress_at, progress_from, progress_to)
225
+ failing = failed_attempts(filter_values(error_class), filter_values(error_step), error_from, error_to)
226
+ scope = scope.where(id: failing.select(:saga_execution_id)) if failing
227
+ scope = scope.where(in_partition(partition.to_s)) unless blank?(partition)
228
+ scope = scope.where(matching_resource(scope, resource.to_s)) unless blank?(resource)
229
+ scope
230
+ end
231
+
232
+ def failed_attempts(classes, steps, from, to)
233
+ return if [classes, steps, from, to].all?(&:nil?)
234
+
235
+ scope = step_attempt_class.where(status: "failed")
236
+ scope = scope.where(error_class: classes) if classes
237
+ scope = scope.where(step_name: steps) if steps
238
+ in_window(scope, :started_at, from, to)
239
+ end
240
+
241
+ def in_partition(partition)
242
+ ::Sequel.|({partition: partition}, {partition: nil, saga_name: partition_saga_names(partition)})
243
+ end
244
+
245
+ def matching_resource(scope, needle)
246
+ pattern = "%#{scope.escape_like(needle)}%"
247
+ ::Sequel.|(::Sequel.ilike(:resource_type, pattern), ::Sequel.ilike(:resource_id, pattern))
248
+ end
249
+
250
+ # Attempts started in the window, joined to their execution. Columns
251
+ # are qualified from here on: both tables have id, status and the
252
+ # timestamps.
253
+ def attempts_with_saga(from, to, saga_name)
254
+ scope = step_attempt_class.dataset
255
+ .join(execution_class.table_name, id: :saga_execution_id)
256
+ .where(attempts_table[:started_at] >= from)
257
+ .where(attempts_table[:started_at] < to)
258
+ sagas = filter_values(saga_name)
259
+ sagas ? scope.where(executions_table[:saga_name] => sagas) : scope
260
+ end
261
+
262
+ def blank?(value) = value.to_s.strip.empty?
263
+ end
264
+ end
265
+ end
266
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.9"
metadata ADDED
@@ -0,0 +1,207 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lepus-saga-sequel
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: sequel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.60'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.60'
40
+ - !ruby/object:Gem::Dependency
41
+ name: pry
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: rake
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: rspec
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: rubocop
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: rubocop-performance
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-rspec
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: standard
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
+ description: Keeps lepus-saga executions and step attempts in Sequel models (PostgreSQL,
139
+ MySQL/MariaDB, SQLite), with migrations for the tables
140
+ email:
141
+ - mgzmaster@gmail.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - CHANGELOG.md
147
+ - Gemfile
148
+ - LICENSE.txt
149
+ - README.md
150
+ - Rakefile
151
+ - docs/brand/BRAND.md
152
+ - docs/brand/apple-touch-icon.png
153
+ - docs/brand/favicon-16.png
154
+ - docs/brand/favicon-32.png
155
+ - docs/brand/favicon.ico
156
+ - docs/brand/favicon.svg
157
+ - docs/brand/icon-512.png
158
+ - docs/brand/lockup-lepus-mono.svg
159
+ - docs/brand/lockup-lepus-on-dark.svg
160
+ - docs/brand/lockup-lepus-on-light.svg
161
+ - docs/brand/lockup-lepus-saga-mono.svg
162
+ - docs/brand/lockup-lepus-saga-on-dark.svg
163
+ - docs/brand/lockup-lepus-saga-on-light.svg
164
+ - docs/brand/lockup-lepus-saga.svg
165
+ - docs/brand/lockup-lepus.svg
166
+ - docs/brand/mark-mono.svg
167
+ - docs/brand/mark-on-dark.svg
168
+ - docs/brand/mark-on-light.svg
169
+ - docs/brand/mark-small-mono.svg
170
+ - docs/brand/mark-small-on-dark.svg
171
+ - docs/brand/mark-small-on-light.svg
172
+ - docs/brand/mark-small.svg
173
+ - docs/brand/mark.svg
174
+ - lib/lepus/saga/sequel.rb
175
+ - lib/lepus/saga/sequel/execution.rb
176
+ - lib/lepus/saga/sequel/migrations/001_create_lepus_saga_tables.rb
177
+ - lib/lepus/saga/sequel/migrations/002_add_lepus_saga_dashboard_indexes.rb
178
+ - lib/lepus/saga/sequel/record.rb
179
+ - lib/lepus/saga/sequel/step_attempt.rb
180
+ - lib/lepus/saga/sequel/version.rb
181
+ - lib/lepus/saga/stores/sequel.rb
182
+ - mise.toml
183
+ homepage: https://github.com/marcosgz/lepus-saga-sequel
184
+ licenses:
185
+ - MIT
186
+ metadata:
187
+ homepage_uri: https://github.com/marcosgz/lepus-saga-sequel
188
+ source_code_uri: https://github.com/marcosgz/lepus-saga-sequel
189
+ changelog_uri: https://github.com/marcosgz/lepus-saga-sequel/blob/master/CHANGELOG.md
190
+ rdoc_options: []
191
+ require_paths:
192
+ - lib
193
+ required_ruby_version: !ruby/object:Gem::Requirement
194
+ requirements:
195
+ - - ">="
196
+ - !ruby/object:Gem::Version
197
+ version: 3.3.0
198
+ required_rubygems_version: !ruby/object:Gem::Requirement
199
+ requirements:
200
+ - - ">="
201
+ - !ruby/object:Gem::Version
202
+ version: '0'
203
+ requirements: []
204
+ rubygems_version: 3.6.9
205
+ specification_version: 4
206
+ summary: Sequel store for Lepus::Saga
207
+ test_files: []