ephemeron 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 032a79004b77bffee915f105cd807beb01fcc60c52e829c478fcf615a8fa8184
4
+ data.tar.gz: 65168d9788376856e6745c083f2a0d467b18deace844719e59024e9027a47db4
5
+ SHA512:
6
+ metadata.gz: 88ddba6984c039180c74433d9dc9447eae247b040700f3de02c0338e6a9ffe95dc3472c7eb982b81a867044d1c214c15cafe0ede0bf9286308d15f59664e403b
7
+ data.tar.gz: 6488f41b9df17130dbedb9f1db5bfa72e1aeded337863e46fe03e898728ed6aa5dfac2666f8cdaaf7004bf0623900f1073195327e06653994d4b974a17f67f1a
@@ -0,0 +1,20 @@
1
+ Copyright 2019 Zbigniew Humeniuk
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,28 @@
1
+ # Ephemeron
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'ephemeron'
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install ephemeron
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'bundler/setup'
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ require 'rdoc/task'
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = 'rdoc'
13
+ rdoc.title = 'Ephemeron'
14
+ rdoc.options << '--line-numbers'
15
+ rdoc.rdoc_files.include('README.md')
16
+ rdoc.rdoc_files.include('lib/**/*.rb')
17
+ end
18
+
19
+ require 'bundler/gem_tasks'
20
+
21
+ require 'rake/testtask'
22
+
23
+ Rake::TestTask.new(:test) do |t|
24
+ t.libs << 'test'
25
+ t.pattern = 'test/**/*_test.rb'
26
+ t.verbose = false
27
+ end
28
+
29
+ task default: :test
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ephemeron/railtie'
4
+
5
+ require 'ephemeron/addons/controller_addons'
6
+ require 'ephemeron/addons/model_addons'
7
+
8
+ require 'ephemeron/writers/thread_store'
9
+ require 'ephemeron/writers/callbacks'
10
+ require 'ephemeron/writers/logger'
11
+ require 'ephemeron/writers/store'
12
+
13
+ require 'ephemeron/errors'
14
+ require 'ephemeron/logger/convert'
15
+ require 'ephemeron/logger/finders'
16
+ require 'ephemeron/settings'
17
+ require 'ephemeron/store/convert'
18
+ require 'ephemeron/store/update'
19
+ require 'ephemeron/validators/before_save'
20
+ require 'ephemeron/validators/store'
21
+ require 'ephemeron/validators/store_element'
22
+ require 'ephemeron/validators/store/allowed_but_unsaved'
23
+ require 'ephemeron/validators/store/unused'
24
+
25
+ module Ephemeron
26
+ module_function
27
+
28
+ def configure
29
+ Ephemeron::Settings::CONFIG.new.tap do |config|
30
+ yield config
31
+ Ephemeron::Settings.configure config
32
+ end
33
+ end
34
+
35
+ def state
36
+ Store.store
37
+ end
38
+
39
+ def logs
40
+ Logger.logs
41
+ end
42
+
43
+ def add(model, as: nil)
44
+ Store.add model, as: as
45
+ end
46
+
47
+ def proxy
48
+ Logger.log nil, :proxy_start
49
+ yield
50
+ Logger.log nil, :proxy_end
51
+ end
52
+
53
+ def get(key)
54
+ Store.get key
55
+ end
56
+
57
+ def used(model_s)
58
+ log_used = ->(model) { Logger.log model, :used }
59
+ if model_s.is_a? ActiveRecord::Base
60
+ log_used.call model_s
61
+ else
62
+ model_s.each(&log_used)
63
+ end
64
+ model_s
65
+ end
66
+
67
+ def allow_save!(model)
68
+ model.tap { |m| Logger.log m, :save_allowed }
69
+ end
70
+
71
+ def skip_save!(model)
72
+ Logger.log model, :skip_save
73
+ end
74
+
75
+ def after_save!(&block)
76
+ Callbacks.add block
77
+ end
78
+
79
+ def finish
80
+ if Store.aliases[nil] != :abort
81
+ Store.validate
82
+ Store.persist
83
+ Callbacks.call
84
+ end
85
+ reset
86
+ end
87
+
88
+ def stop
89
+ Store.aliases[nil] = :abort
90
+ end
91
+
92
+ def reset
93
+ Store.reset
94
+ Logger.reset
95
+ Callbacks.reset
96
+ true
97
+ end
98
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module ControllerAddons
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_action -> { Ephemeron.finish }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module ModelAddons
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_find lambda { |model|
9
+ Ephemeron.add model
10
+ }
11
+
12
+ after_validation lambda { |model|
13
+ Logger.log model, :used, soft: true
14
+ }
15
+
16
+ before_save lambda { |model|
17
+ Validators::BeforeSave.call model
18
+ }
19
+
20
+ after_create lambda { |model|
21
+ Store::Update.call model
22
+ }
23
+
24
+ after_save lambda { |model|
25
+ Logger.log model, :saved
26
+ }
27
+
28
+ after_destroy lambda { |model|
29
+ Ephemeron::Store.destroy model
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class LoggerError < ArgumentError
5
+ end
6
+
7
+ class StoreError < StandardError
8
+ end
9
+
10
+ class PersistenceError < StandardError
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Logger
5
+ module Convert
6
+ module_function
7
+
8
+ def call(model, event)
9
+ "#{Store::Convert.call(model) if model} #{event}".strip
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Logger
5
+ module Finders
6
+ module_function
7
+
8
+ def find_all_events_for(model, *events)
9
+ Logger.logs
10
+ .filter { |l| events.any? { |e| l == Logger::Convert.call(model, e) } }
11
+ end
12
+
13
+ def find_first_allowed_but_unsaved
14
+ ary = Logger.logs
15
+ .reverse
16
+ .map { |line| line.split ' ' }
17
+ .find { |arr| %w[save_allowed saved].include?(arr[1]) }
18
+ return nil if ary.nil? || ary[1] == 'saved'
19
+
20
+ Store.store[ary[0]]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Railtie < ::Rails::Railtie
5
+ end
6
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Settings
5
+ CONFIG = Struct.new :skip_models
6
+
7
+ class << self
8
+ attr_writer :skipped_models
9
+
10
+ def skipped_models
11
+ @skipped_models || []
12
+ end
13
+
14
+ def configure(config)
15
+ Ephemeron::Settings.skipped_models = config.skip_models
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Store
5
+ class Convert
6
+ class << self
7
+ def call(model_s, as_new_record: false)
8
+ return class_name_and_id(model_s, as_new_record) if model_s.is_a?(ActiveRecord::Base)
9
+
10
+ model_s.map { |model| class_name_and_id(model) }.join ', '
11
+ end
12
+
13
+ private
14
+
15
+ def class_name_and_id(model, as_new_record = false)
16
+ new_record = as_new_record || model.new_record?
17
+ name = model.class.name.downcase
18
+ id = new_record ? "new_record##{model.object_id}" : model.id
19
+ "#{name}##{id}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Store
5
+ module Update
6
+ module_function
7
+
8
+ def call(model)
9
+ return unless Store.has? model, as_new_record: true
10
+
11
+ Store.add model
12
+ Store.store.delete Convert.call(model, as_new_record: true)
13
+
14
+ Store.aliases.find_all { |_, v| v == Convert.call(model, as_new_record: true) }
15
+ .to_h.keys
16
+ .each { |key| Store.aliases[key] = Convert.call(model) }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module Validators
5
+ module BeforeSave
6
+ module_function
7
+
8
+ def call(model)
9
+ return unless Ephemeron::Store.has? model
10
+
11
+ last_event = Ephemeron::Logger::Finders
12
+ .find_all_events_for(model, :save_allowed, :saved)
13
+ .last
14
+ return if last_event == Logger::Convert.call(model, :save_allowed)
15
+
16
+ msg = "prior persistence for #{Ephemeron::Store::Convert.call(model)} is prohibited"
17
+ raise(PersistenceError, msg)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module Validators
5
+ module Store
6
+ module_function
7
+
8
+ def call
9
+ AllowedButUnsaved.call
10
+ Unused.call
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module Validators
5
+ module Store
6
+ module AllowedButUnsaved
7
+ module_function
8
+
9
+ def call
10
+ model = Ephemeron::Logger::Finders.find_first_allowed_but_unsaved
11
+ return if model.nil?
12
+
13
+ raise(
14
+ PersistenceError,
15
+ "save allowed for #{Ephemeron::Store::Convert.call(model)} but never saved"
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module Validators
5
+ module Store
6
+ module Unused
7
+ module_function
8
+
9
+ def call
10
+ models = Ephemeron::Store
11
+ .store
12
+ .filter { |_, model| !model.changed? && !Logger.has?(model, :used) }
13
+ .values
14
+ return if models.empty?
15
+
16
+ model_ids = Ephemeron::Store::Convert.call(models)
17
+ raise(
18
+ StoreError,
19
+ "#{model_ids} were added to store but neither fetched nor changed"
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ module Validators
5
+ module StoreElement
6
+ module_function
7
+
8
+ def call(model)
9
+ return false if Logger.logs.last == Logger::Convert.call(nil, :proxy_start)
10
+ return false unless model.is_a?(ActiveRecord::Base)
11
+ return false if Settings.skipped_models.include? model.class.name
12
+
13
+ true
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Callbacks < ThreadStore
5
+ attribute :ts_after_save_callbacks
6
+
7
+ def after_save_callbacks
8
+ self.ts_after_save_callbacks ||= []
9
+ end
10
+
11
+ def add(callback)
12
+ after_save_callbacks << callback
13
+ end
14
+
15
+ def call
16
+ after_save_callbacks.each(&:call)
17
+ end
18
+
19
+ def reset
20
+ self.ts_after_save_callbacks = []
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Logger < ThreadStore
5
+ attribute :ts_logs
6
+
7
+ PROXY_EVENTS = %i[proxy_start proxy_end].freeze
8
+ VALID_EVENTS = %i[saved save_allowed skip_save used] + PROXY_EVENTS
9
+
10
+ def logs
11
+ self.ts_logs ||= []
12
+ end
13
+
14
+ def has?(model, event)
15
+ validate model, event
16
+ logs.include? Convert.call(model, event)
17
+ end
18
+
19
+ def log(model, event, soft: false)
20
+ validate model, event
21
+ logs << Convert.call(model, event)
22
+ rescue LoggerError, StoreError => e
23
+ raise e unless soft
24
+ end
25
+
26
+ def reset
27
+ self.ts_logs = []
28
+ end
29
+
30
+ private
31
+
32
+ def validate(model, event)
33
+ raise(LoggerError, "event (#{event}) is invalid") if VALID_EVENTS.exclude?(event)
34
+ return true if validate_proxy_events model, event
35
+
36
+ raise(StoreError, "can't #{event_name_for_validate event} for nil") if model.nil?
37
+ return true if Store.has?(model)
38
+ return true if event == :saved
39
+
40
+ raise StoreError, "can't #{event_name_for_validate event} " \
41
+ "if #{Store::Convert.call model} is not in store"
42
+ end
43
+
44
+ def validate_proxy_events(model, event)
45
+ return false if PROXY_EVENTS.exclude?(event)
46
+ return true if model.nil?
47
+
48
+ raise LoggerError, 'Proxy events can be logged for nil only'
49
+ end
50
+
51
+ def event_name_for_validate(event)
52
+ if event == :used
53
+ 'mark as used'
54
+ else
55
+ 'log'
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class Store < ThreadStore
5
+ attribute :ts_store, :ts_aliases
6
+
7
+ def store
8
+ self.ts_store ||= {}
9
+ end
10
+
11
+ def aliases
12
+ self.ts_aliases ||= {}
13
+ end
14
+
15
+ def has?(model, as_new_record: false)
16
+ !store[Convert.call(model, as_new_record: as_new_record)].nil?
17
+ end
18
+
19
+ def add(model, as: nil)
20
+ return unless Validators::StoreElement.call(model)
21
+
22
+ key = Convert.call model
23
+ in_store = !store[key].nil?
24
+ store[key] = model unless in_store
25
+ if !as.nil?
26
+ aliases[as] = key
27
+ elsif in_store
28
+ raise StoreError, "#{key} is already in the store"
29
+ end
30
+ model
31
+ end
32
+
33
+ def get(key)
34
+ store[aliases[key]].tap do |model|
35
+ Logger.log(model, :used) if model
36
+ end
37
+ end
38
+
39
+ def validate
40
+ Validators::Store.call
41
+ end
42
+
43
+ def persist
44
+ changed_models = store.filter { |_, v| v.changed? }.values
45
+ .filter { |model| !Logger.has?(model, :skip_save) }
46
+ ActiveRecord::Base.transaction do
47
+ changed_models.each do |model|
48
+ Ephemeron.allow_save! model
49
+ model.save!
50
+ end
51
+ end
52
+ end
53
+
54
+ def destroy(model)
55
+ key = Convert.call model
56
+ return if store[key].nil?
57
+
58
+ store.delete key
59
+ end
60
+
61
+ def reset
62
+ self.ts_store = {}
63
+ self.ts_aliases = {}
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephemeron
4
+ class ThreadStore < ActiveSupport::CurrentAttributes
5
+ def reset
6
+ raise 'not implemented!'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # desc "Explaining what the task does"
3
+ # task :ephemeron do
4
+ # # Task goes here
5
+ # end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ephemeron
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Zbigniew Humeniuk
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-01-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '7.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: jb
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.7.0
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.7.0
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec-mocks
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.9'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.9'
61
+ - !ruby/object:Gem::Dependency
62
+ name: sqlite3
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ description: |
76
+ Ephemeron improves the performance of your app.
77
+ It takes on itself the persistence of the ActiveRecord objects.
78
+ It protects you from saving the same object many times.
79
+ It checks whether a fetched from a database object was used.
80
+ It allows you to eliminate the controller's before_actions that are unnecessarily called.
81
+ Ephemeron works in the context of the thread and does the bulk of its job (i.a. persistence) at the end of the thread's lifecycle.
82
+ Although, you can trigger the finalization at any given moment.
83
+ You don't have to make a distinction in the code for the part that is responsible for the domain logic and the other responsible for the application layers.
84
+ email:
85
+ - hello@artofcode.co
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - MIT-LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - lib/ephemeron.rb
94
+ - lib/ephemeron/addons/controller_addons.rb
95
+ - lib/ephemeron/addons/model_addons.rb
96
+ - lib/ephemeron/errors.rb
97
+ - lib/ephemeron/logger/convert.rb
98
+ - lib/ephemeron/logger/finders.rb
99
+ - lib/ephemeron/railtie.rb
100
+ - lib/ephemeron/settings.rb
101
+ - lib/ephemeron/store/convert.rb
102
+ - lib/ephemeron/store/update.rb
103
+ - lib/ephemeron/validators/before_save.rb
104
+ - lib/ephemeron/validators/store.rb
105
+ - lib/ephemeron/validators/store/allowed_but_unsaved.rb
106
+ - lib/ephemeron/validators/store/unused.rb
107
+ - lib/ephemeron/validators/store_element.rb
108
+ - lib/ephemeron/version.rb
109
+ - lib/ephemeron/writers/callbacks.rb
110
+ - lib/ephemeron/writers/logger.rb
111
+ - lib/ephemeron/writers/store.rb
112
+ - lib/ephemeron/writers/thread_store.rb
113
+ - lib/tasks/ephemeron_tasks.rake
114
+ homepage: https://artofcode.co
115
+ licenses:
116
+ - MIT
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 3.1.2
134
+ signing_key:
135
+ specification_version: 4
136
+ summary: The organic DDD
137
+ test_files: []