event_engine-store 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0888fb8cb32a3a92676f641ce96b85d56753a890bdecf9027c9521d3b642ec9c'
4
- data.tar.gz: b488a04eba91857bcc65ae37bdb4cf0608d94a8f8c4bc6d3e42798810fd7a264
3
+ metadata.gz: 11e8057d3dfa1adb99b82eb9e6add21d9f5bfc3fd5a06895ba7e391d136e11ae
4
+ data.tar.gz: 60c1108aef5cae04c14b1b9f77baae03c98a0a8628bba364da51ad267359ee02
5
5
  SHA512:
6
- metadata.gz: fa6fd5beff74cfcea49f0917bbda4f82448592a2c1893d093d5074c97dc41f4cd506c4536398e4b6fe0061e5397414f44e48ca9cf6c586d14fb95e26a5624bb7
7
- data.tar.gz: 964a48580c13ddb2f1be6d0803e8d0ce26b229fe62d6521c0fdd17f82e228ea3485898a2b9b48e2b23cb505e3009e91feac87873f05530e7e2a7e8e249378c3e
6
+ metadata.gz: 5356e1efded2136e940c183b4f7dc84d02c7fe74929d4a60c81d3efeb961992b7de81818ee7c09576c0cec265e6a3a99c088f36d39e8587a819f0c3ac55789a6
7
+ data.tar.gz: 7823ee9ae1e1d3f2ded90f5c664a2e6996bded30620e8e4d52273814b211702b31f4fad868e1f5816b182601656fac4cbd0e59f9efdc4112bb4995cce50fa7e2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ All notable changes to this project are documented here, following
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.1] - 2026-11-07
10
+
11
+ ### Added
12
+
13
+ - The gem ships its expert agents, so an app that installs it can ask an event_engine-store expert how events are stored, replayed and built into projections.
14
+
15
+ ### Fixed
16
+
17
+ - The stored events migration runs on Rails 7.1.
18
+
9
19
  ## [0.1.0] - 2026-07-29
10
20
 
11
21
  First published release of `event_engine-store`, the durable event record for the
data/README.md CHANGED
@@ -67,14 +67,14 @@ itself up at boot. Once migrated, every dispatched event is recorded automatical
67
67
 
68
68
  ## How it hooks into the core
69
69
 
70
- At Rails boot the engine registers **two** handlers with the core, for all levels:
70
+ At Rails boot the engine registers **two** handlers with the core, for every process type:
71
71
 
72
72
  ```ruby
73
73
  # lib/event_engine/store/engine.rb
74
74
  initializer "event_engine.store.register_recorder" do
75
75
  config.after_initialize do
76
- EventEngine.register_handler(Recorder.new, levels: :all)
77
- EventEngine.register_handler(ProjectionDispatcher.new, levels: :all)
76
+ EventEngine.register_handler(Recorder.new, process_types: :all)
77
+ EventEngine.register_handler(ProjectionDispatcher.new, process_types: :all)
78
78
  end
79
79
  end
80
80
  ```
@@ -85,9 +85,10 @@ So on every `EventEngine.<event>` call:
85
85
  2. `ProjectionDispatcher#call(event)` calls `apply(event)` on each registered
86
86
  projection.
87
87
 
88
- > Because both register at `levels: :all`, the store records **every** level —
89
- > including non-durable levels 1 and 2. That's by design: the store is your record of
90
- > what happened, independent of how it was delivered. If you only want to record some
88
+ > Because both register at `process_types: :all`, the store records events of
89
+ > **every** process type, whichever processor `config/event_rules.yml` routes them to.
90
+ > That's by design: the store is your record of what happened, independent of how it
91
+ > was processed. If you only want to record some
91
92
  > events, see [Recording only some events](#recording-only-some-events).
92
93
 
93
94
  ---
@@ -103,7 +104,7 @@ persisted records), so an attempt to update raises `ActiveRecord::ReadOnlyRecord
103
104
  | `event_name` | string | ✓ | NOT NULL |
104
105
  | `event_type` | string | | classification |
105
106
  | `event_version` | integer | | schema version |
106
- | `event_level` | integer | | dispatched level |
107
+ | `process_type` | string | | processor the event was routed to |
107
108
  | `payload` | json | | event data |
108
109
  | `metadata` | json | | context (actor, request id, …) |
109
110
  | `occurred_at` | datetime | ✓ | logical event time |
@@ -120,7 +121,7 @@ create_table :event_engine_store_stored_events do |t|
120
121
  t.string :event_name, null: false
121
122
  t.string :event_type
122
123
  t.integer :event_version
123
- t.integer :event_level
124
+ t.string :process_type
124
125
  t.json :payload
125
126
  t.json :metadata
126
127
  t.datetime :occurred_at
@@ -212,7 +213,7 @@ module EventEngine
212
213
  event_name: event.event_name,
213
214
  event_type: event.event_type,
214
215
  event_version: event.event_version,
215
- event_level: event.event_level,
216
+ process_type: event.process_type,
216
217
  payload: event.payload,
217
218
  metadata: event.metadata,
218
219
  occurred_at: event.occurred_at,
@@ -293,7 +294,7 @@ EventEngine::Store::Recorder.prepend(RecordDomainOnly)
293
294
  ```
294
295
 
295
296
  **Why:** keep the log focused on domain/audit-worthy events and avoid recording noisy
296
- level-1 system pings.
297
+ system events.
297
298
 
298
299
  ### Replacing the recorder entirely
299
300
 
@@ -308,7 +309,7 @@ class MyAuditRecorder
308
309
  end
309
310
 
310
311
  Rails.application.config.after_initialize do
311
- EventEngine.register_handler(MyAuditRecorder.new, levels: :all)
312
+ EventEngine.register_handler(MyAuditRecorder.new, process_types: :all)
312
313
  end
313
314
  ```
314
315
 
@@ -362,7 +363,7 @@ log in append order (ordered by `id`, batched via `find_each`):
362
363
 
363
364
  ```ruby
364
365
  EventEngine::Store::Replay.each do |event|
365
- # event is a fully-rehydrated EventEngine::Event (symbol-keyed payload)
366
+ # event is an EventEngine::Event rebuilt from its row
366
367
  puts "#{event.occurred_at} #{event.event_name}"
367
368
  end
368
369
 
@@ -371,6 +372,13 @@ enum = EventEngine::Store::Replay.each
371
372
  enum.count
372
373
  ```
373
374
 
375
+ A replayed event differs from the one that was dispatched:
376
+
377
+ - `payload` and `metadata` have **string keys**, because they are read back from JSON
378
+ columns (`event.payload["weight"]`, not `event.payload[:weight]`).
379
+ - `event_name` and `process_type` are **strings**, read back from string columns.
380
+ - `subject` and `domain` are **nil**, because the table has no columns for them.
381
+
374
382
  Use replay to rebuild read models, backfill a new projection, or audit history.
375
383
 
376
384
  ---
@@ -386,7 +394,7 @@ class OrdersByDay
386
394
  attr_reader :counts
387
395
 
388
396
  def apply(event)
389
- @counts[event.occurred_at.to_date] += 1 if event.event_name == :order_placed
397
+ @counts[event.occurred_at.to_date] += 1 if event.event_name.to_s == "order_placed"
390
398
  end
391
399
  end
392
400
 
data/Rakefile CHANGED
@@ -7,3 +7,5 @@ require "bundler/gem_tasks"
7
7
 
8
8
  task test: "app:test"
9
9
  task default: :test
10
+
11
+ require "the_local/rake"
@@ -1,4 +1,4 @@
1
- class CreateEventEngineStoreStoredEvents < ActiveRecord::Migration[8.1]
1
+ class CreateEventEngineStoreStoredEvents < ActiveRecord::Migration[7.1]
2
2
  def change
3
3
  create_table :event_engine_store_stored_events do |t|
4
4
  t.string :event_name, null: false
@@ -1,5 +1,5 @@
1
1
  module EventEngine
2
2
  module Store
3
- VERSION = "0.1.0"
3
+ VERSION = "0.1.1"
4
4
  end
5
5
  end
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: event_engine-store-develop
3
+ description: Use PROACTIVELY for reading an app's recorded event history on event_engine-store — querying the stored-events log, replaying it in order, writing and registering projections, rebuilding a read model from the log, and changing which events get recorded — MUST BE USED instead of hand-rolling an audit table, an event-history query, or a script that recomputes a read model from scratch.
4
+ tools: Read, Write, Edit, Grep
5
+ scope: event store — recording every dispatched event to an append-only table, replaying the log in order, and rebuilding projections from it
6
+ ---
7
+
8
+ This local builds on a host app's event record with event_engine-store, following
9
+ the steps below in order. Where a step names a decision, it asks the developer
10
+ instead of choosing.
11
+
12
+ ## What event_engine-store is
13
+
14
+ A Rails engine that writes every event event_engine dispatches to an append-only
15
+ table, and reads that table back as events. Fire this local when an app needs its
16
+ event history queried, a read model kept up to date from events, a read model
17
+ rebuilt or backfilled from past events, or a change to what the log records.
18
+
19
+ ## Interface
20
+
21
+ - `EventEngine::Store::StoredEvent` — the ActiveRecord model for the log, one row
22
+ per dispatched event. Query it like any model; a saved row cannot be changed.
23
+ - `EventEngine::Store::Replay.each` — yields every recorded event, oldest first,
24
+ as an event object. Without a block it returns an Enumerator.
25
+ - `EventEngine::Store.register_projection` — adds a projection that receives
26
+ every event as it is dispatched.
27
+ - `EventEngine::Store.rebuild` — replays the whole log into one projection.
28
+ - `EventEngine::Store.projections` — the list of registered projections.
29
+ - `EventEngine::Store.reset_projections!` — removes every registered projection.
30
+ - `EventEngine::Store::Recorder` — the handler that writes each dispatched event
31
+ to the log. Prepend a module to it to change what is recorded.
32
+
33
+ ## How to use it
34
+
35
+ The `event_engine_store_stored_events` table must exist in the host app. If it
36
+ does not, hand off to `event_engine-store-install` first and come back.
37
+
38
+ 1. Query the log with plain ActiveRecord on `EventEngine::Store::StoredEvent`. Its
39
+ columns are `event_name`, `event_type`, `event_version`, `process_type`,
40
+ `payload`, `metadata`, `occurred_at`, `idempotency_key`, `aggregate_type`,
41
+ `aggregate_id`, `aggregate_version`, and `created_at`. `event_name`,
42
+ `occurred_at`, and `idempotency_key` are indexed.
43
+
44
+ ```ruby
45
+ EventEngine::Store::StoredEvent.where(aggregate_type: "Order", aggregate_id: order.id.to_s).order(:id)
46
+ ```
47
+
48
+ The model has no scopes. To add some, decorate it in an initializer inside
49
+ `Rails.application.config.to_prepare`, so the decoration is reapplied when
50
+ Rails reloads the model:
51
+
52
+ ```ruby
53
+ Rails.application.config.to_prepare do
54
+ EventEngine::Store::StoredEvent.class_eval do
55
+ scope :named, ->(name) { where(event_name: name.to_s) }
56
+ end
57
+ end
58
+ ```
59
+
60
+ 2. Replay the log when you need events back as event objects rather than rows:
61
+
62
+ ```ruby
63
+ EventEngine::Store::Replay.each { |event| puts "#{event.occurred_at} #{event.event_name}" }
64
+ ```
65
+
66
+ Events come back in insertion order, loaded in batches. The Enumerator returned
67
+ without a block runs its query each time it is iterated, so it sees rows
68
+ written after it was created.
69
+
70
+ 3. Write a projection. A projection is any object that responds to
71
+ `apply(event)`. Ask the developer what read model it maintains and where that
72
+ state lives (memory, a table, a cache) — there is no default.
73
+
74
+ ```ruby
75
+ class OrdersPerDay
76
+ attr_reader :counts
77
+
78
+ def initialize
79
+ @counts = Hash.new(0)
80
+ end
81
+
82
+ def apply(event)
83
+ @counts[event.occurred_at.to_date] += 1 if event.event_name.to_s == "order_placed"
84
+ end
85
+ end
86
+ ```
87
+
88
+ 4. Register it for live updates, once per process, from an initializer:
89
+
90
+ ```ruby
91
+ Rails.application.config.after_initialize do
92
+ EventEngine::Store.register_projection(OrdersPerDay.new)
93
+ end
94
+ ```
95
+
96
+ From then on it receives every dispatched event, of every process type, after
97
+ the event has been written to the log. Registered projections run in
98
+ registration order.
99
+
100
+ 5. Rebuild when the projection's state must reflect history — a new projection, a
101
+ changed `apply`, or lost state:
102
+
103
+ ```ruby
104
+ EventEngine::Store.rebuild(projection)
105
+ ```
106
+
107
+ It calls `apply` once per recorded event, oldest first. It does not clear the
108
+ projection first, and the projection does not need to be registered. Ask the
109
+ developer whether rebuilding should start from a fresh projection or cleared
110
+ state, and whether it can run while events are still being emitted — an event
111
+ emitted during a rebuild of a registered projection can reach it twice.
112
+
113
+ 6. In tests, call `EventEngine::Store.reset_projections!` in teardown after
114
+ registering a projection, and read `EventEngine::Store.projections` to assert
115
+ on what is registered.
116
+
117
+ 7. Change what is recorded only if the developer asks for it, by prepending a
118
+ module to `EventEngine::Store::Recorder` from an initializer. Its `call(event)`
119
+ returns the created row. Ask which change they want:
120
+
121
+ - Record a subset — return early for events to skip, otherwise call `super`.
122
+ - Never let recording break an emit — rescue around `super`, log, return
123
+ `nil`. A failed write is then lost unless it is retried.
124
+ - Record extra columns — add them with a host migration, then have `call`
125
+ create the row itself with the default attributes plus the new ones. The
126
+ default recorder writes only its fixed attributes, so new columns stay
127
+ `NULL` otherwise.
128
+ - Stop recording entirely — define `call(_event)` with an empty body.
129
+ Registered projections still run.
130
+
131
+ ```ruby
132
+ module RecordOrdersOnly
133
+ def call(event)
134
+ return unless event.event_name.to_s.start_with?("order_")
135
+
136
+ super
137
+ end
138
+ end
139
+
140
+ EventEngine::Store::Recorder.prepend(RecordOrdersOnly)
141
+ ```
142
+
143
+ ## Conventions
144
+
145
+ - Never update or destroy a saved `StoredEvent`. `update` and `destroy` on a saved
146
+ row raise `ActiveRecord::ReadOnlyRecord`. Correct history by emitting a new
147
+ event.
148
+ - Recording and projections run synchronously inside the emit call. A database
149
+ error while recording, or an exception raised by `apply`, propagates to the
150
+ code that emitted the event and stops later handlers and projections. Keep
151
+ `apply` fast; enqueue a job for heavy work.
152
+ - A replayed event differs from a live one. `event_name` comes back as a string,
153
+ `payload` and `metadata` come back with string keys, and `subject` and `domain`
154
+ are `nil` because they are not stored. Write `apply` to accept both: compare
155
+ `event_name.to_s`, and read payload keys as strings or with indifferent access.
156
+ - `register_projection` keeps a plain in-memory list. Registering the same
157
+ projection twice applies every event to it twice, and each process registers
158
+ its own.
159
+ - The idempotency key is not unique, so the same event emitted twice is recorded
160
+ twice. A projection that must count each event once has to deduplicate itself.
161
+ - Do not call `EventEngine.reset_handlers!` to stop recording. It removes every
162
+ handler, including the one that feeds registered projections.
163
+ - Out of scope for this local: adding the gem and creating the table
164
+ (`event_engine-store-install`), and defining, routing, or emitting events
165
+ (`event_engine-develop`).
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: event_engine-store-info
3
+ description: Use to learn what event_engine-store offers — the permanent append-only record of every dispatched event, replay of that record in order, and projections kept live and rebuilt from it.
4
+ tools: Read
5
+ scope: event store — recording every dispatched event to an append-only table, replaying the log in order, and rebuilding projections from it
6
+ ---
7
+
8
+ This local explains event_engine-store and the vocabulary it uses. It changes
9
+ nothing and gives no steps — read it to orient, then go to the local that owns the
10
+ work.
11
+
12
+ ## What event_engine-store is
13
+
14
+ event_engine-store is a Rails engine that adds a permanent record to an app built
15
+ on event_engine. Once installed, every event event_engine dispatches is written as
16
+ one row to a table the host app owns, whatever its process type. Rows are never
17
+ updated: the table is the history of what happened, in the order it happened.
18
+
19
+ Reach for it when an app needs to answer "what happened, and when" from a durable
20
+ log, rebuild a read model (counters, dashboards, search indexes) from history, or
21
+ backfill a new read model from events recorded before it existed. It does not
22
+ aggregate or compute metrics, and it does not deliver events anywhere — it records
23
+ them and reads them back.
24
+
25
+ ## Interface
26
+
27
+ This local documents no commands — it is background only.
28
+
29
+ The `event_engine-store-install` local owns adding the gem to a host app and
30
+ creating its table. The `event_engine-store-develop` local owns everything an app
31
+ does with the record: querying it, replaying it, registering and rebuilding
32
+ projections, and changing what gets recorded.
33
+
34
+ ## How to use it
35
+
36
+ One decision: is the store already in the app?
37
+
38
+ - Adding the gem to an app for the first time, or the table is missing →
39
+ `event_engine-store-install`.
40
+ - Querying the log, replaying it, writing a projection, or changing what is
41
+ recorded → `event_engine-store-develop`.
42
+
43
+ If you only needed the vocabulary, you have it — stop here.
44
+
45
+ ## Conventions
46
+
47
+ - **Stored event** — one row in the log, holding one dispatched event's name,
48
+ type, version, process type, payload, metadata, occurred-at time, idempotency
49
+ key, and aggregate type / id / version. The event's subject and domain are not
50
+ stored.
51
+ - **Append-only** — a row is written once and never changed. Order is the row's
52
+ insertion order.
53
+ - **Recording** — happens automatically for every dispatched event, inside the
54
+ emit call, with no configuration.
55
+ - **Replay** — reading the log back from the first row to the last, as event
56
+ objects.
57
+ - **Projection** — an object with an `apply(event)` method that builds a read
58
+ model from events. A registered projection receives each event as it is
59
+ dispatched; any projection can be rebuilt by replaying the whole log into it.
60
+ - **Companion gems** — event_engine (required) defines and dispatches events.
61
+ event_engine-delivery keeps a temporary outbox for delivery; the two can run
62
+ side by side.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: event_engine-store-install
3
+ description: Use to hook event_engine-store into a Rails app — adding the gem, copying and running its stored-events migration, and confirming dispatched events are being recorded.
4
+ tools: Bash, Read, Edit
5
+ scope: event store — recording every dispatched event to an append-only table, replaying the log in order, and rebuilding projections from it
6
+ ---
7
+
8
+ This local follows the steps below exactly, in order, and invents none. Where a
9
+ step names a decision, it asks the developer instead of choosing.
10
+
11
+ ## What event_engine-store is
12
+
13
+ A Rails engine that records every event event_engine dispatches into an
14
+ append-only table in the host app — hook it in when an app needs a permanent,
15
+ replayable log of its events.
16
+
17
+ ## Interface
18
+
19
+ - `bin/rails event_engine_store:install:migrations` — copies the migration that creates the
20
+ `event_engine_store_stored_events` table into the host app's `db/migrate/`.
21
+
22
+ ## How to use it
23
+
24
+ 1. Confirm event_engine is already installed in the host app — its initializer
25
+ exists and the app boots with its schema catalog. If not, hand off to the
26
+ `event_engine-install` local first and come back. This gem requires
27
+ `event_engine` 0.2.1 or later.
28
+
29
+ 2. Add the gem to the host app's `Gemfile`:
30
+
31
+ ```ruby
32
+ gem "event_engine-store"
33
+ ```
34
+
35
+ Ask the developer whether to take the released gem, pin a version, or track the
36
+ source repository (`github: "DYB-Development/event_engine-store"`) — do not
37
+ pick for them. The host must be a Rails app; this gem is a Rails engine.
38
+
39
+ 3. Install it:
40
+
41
+ ```bash
42
+ bundle install
43
+ ```
44
+
45
+ 4. Copy the migration into the host app:
46
+
47
+ ```bash
48
+ bin/rails event_engine_store:install:migrations
49
+ ```
50
+
51
+ It writes one new file under `db/migrate/` that creates
52
+ `event_engine_store_stored_events`. The command copies pending migrations from
53
+ every installed engine, not only this one — show the developer the list of new
54
+ files before continuing.
55
+
56
+ 5. Check the host's Rails version. The copied migration is declared as
57
+ `ActiveRecord::Migration[8.1]`, which Rails versions before 8.1 reject. If the
58
+ host runs an older Rails, ask the developer whether to change the bracketed
59
+ version in the copied file to the host's own Rails version — do not change it
60
+ without asking.
61
+
62
+ 6. Run the migration and commit both the migration and the updated schema file:
63
+
64
+ ```bash
65
+ bin/rails db:migrate
66
+ ```
67
+
68
+ 7. Verify the table exists:
69
+
70
+ ```bash
71
+ bin/rails runner 'p ActiveRecord::Base.connection.table_exists?("event_engine_store_stored_events")'
72
+ ```
73
+
74
+ `true` means the install is done. `false` means step 6 did not run against
75
+ this environment's database.
76
+
77
+ 8. Verify recording. In a Rails console, note
78
+ `ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM event_engine_store_stored_events")`,
79
+ emit any catalogued event the app already emits, and read the count again. It
80
+ grows by one per dispatched event.
81
+
82
+ ## Conventions
83
+
84
+ - There is no generator, no initializer, and no configuration. Once the gem is in
85
+ the bundle and the table exists, every dispatched event is recorded at boot
86
+ without any host code.
87
+ - Recording is synchronous and inside the emit call, so an emit fails if the
88
+ table is missing. Do not deploy the gem to an environment before its migration
89
+ has run there.
90
+ - The table has no foreign keys and no unique index. The idempotency key is
91
+ indexed but not unique, so a duplicate emit is recorded twice.
92
+ - Re-running step 4 copies nothing that is already in `db/migrate/`.
93
+ - Out of scope for this local: querying or replaying the log, projections, and
94
+ changing what gets recorded. All of that belongs to `event_engine-store-develop`.
@@ -0,0 +1,22 @@
1
+ scope: event store — recording every dispatched event to an append-only table, replaying the log in order, and rebuilding projections from it
2
+
3
+ install:
4
+ - bin/rails event_engine_store:install:migrations
5
+
6
+ develop:
7
+ - EventEngine::Store::StoredEvent
8
+ - EventEngine::Store::Replay.each
9
+ - EventEngine::Store.register_projection
10
+ - EventEngine::Store.rebuild
11
+ - EventEngine::Store.projections
12
+ - EventEngine::Store.reset_projections!
13
+ - EventEngine::Store::Recorder
14
+
15
+ sources:
16
+ - lib/event_engine/store.rb
17
+ - lib/event_engine/store/engine.rb
18
+ - lib/event_engine/store/recorder.rb
19
+ - lib/event_engine/store/replay.rb
20
+ - lib/event_engine/store/projection_dispatcher.rb
21
+ - app/models/event_engine/store/stored_event.rb
22
+ - db/migrate/20260605000001_create_event_engine_store_stored_events.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_engine-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -103,6 +103,10 @@ files:
103
103
  - lib/event_engine/store/replay.rb
104
104
  - lib/event_engine/store/version.rb
105
105
  - lib/tasks/event_engine/store_tasks.rake
106
+ - the_local/agents/event_engine-store-develop.md
107
+ - the_local/agents/event_engine-store-info.md
108
+ - the_local/agents/event_engine-store-install.md
109
+ - the_local/interface.yml
106
110
  homepage: https://github.com/DYB-Development/event_engine-store
107
111
  licenses:
108
112
  - MIT