active_chronicle 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e9591048e619042697a08f52bca32f1eb5a29f799df5e2712821a72e4daa03f
4
+ data.tar.gz: 47508468ff233dd841e2e07295e130fb8bc4f84b8a857cc52eeeaac9e396ea6f
5
+ SHA512:
6
+ metadata.gz: 3792343f538b15c26a8bae3fb96e7aba9f0321e0ea011f6868eb218b96a53002acdb25e0285cbf3605c0961a5495c7fc38beb35e0fe65bb0cd3c782416d4167c
7
+ data.tar.gz: ffff69d99a41a44c0ac23493ef2cfad47e89a08b0bf39aa20df78fd7d1f5e9048987aebb4cb1140d9a270ce599c67d52454cacb995369061ccecef65d1c96445
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ ### Core
6
+
7
+ - Added Active Record integration through `Chronicle::Model`.
8
+ - Added Datomic attribute declarations and schema migration helpers.
9
+ - Added Datalog compilation for predicates, projections, `IN` values, and pull queries.
10
+ - Added association hydration from Datomic pull results.
11
+ - Added `.as_of` and `.since` time-travel relations.
12
+ - Added coordinated Datomic and relational transactions with compensating retractions.
13
+ - Added retry backoff, jitter, circuit breaking, and Fiber-aware sleeping.
14
+
15
+ ### Transports
16
+
17
+ - Added CRuby transport support for Datomic REST with EDN requests and responses.
18
+ - Added JRuby Client API support through a Datomic peer server.
19
+ - Added JRuby Peer API support for direct transactor connections.
20
+ - Added explicit peer transport selection with `transport: peer`.
21
+ - Added Datomic jar loading for JRuby containers.
22
+
23
+ ### Examples
24
+
25
+ - Added `news_feed`, a CRuby Rails application that displays story revisions.
26
+ - Added `wallet`, a JRuby Rails application that uses the Datomic Client API to track balance changes.
27
+ - Added `animal_tracker`, a JRuby Rails application that uses the Datomic Peer API to draw historical paths.
28
+ - Added `cross_store`, a CRuby Rails application that references Datomic customer IDs from SQLite purchases.
29
+ - Added a real cross-store benchmark for coordinated Datomic and SQLite writes.
30
+ - Added a Docker Compose stack that installs the Datomic distribution in a container and runs the transactor, peer server, REST service, and all examples.
31
+
32
+ ### Tooling and Release
33
+
34
+ - Raised the supported Ruby version to 3.4.5.
35
+ - Added RuboCop CI validation and a pre-commit quality hook.
36
+ - Added SimpleCov and Codecov reporting.
37
+ - Limited the published gem to library code and release documentation.
38
+ - Excluded specs, CI files, examples, and development files from the gem archive.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Cunha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # Active Chronicle
2
+
3
+ [![CI](https://github.com/Thrashattack/chronicle/actions/workflows/ci.yml/badge.svg)](https://github.com/Thrashattack/chronicle/actions/workflows/ci.yml)
4
+ [![Coverage](https://codecov.io/gh/Thrashattack/chronicle/branch/main/graph/badge.svg)](https://codecov.io/gh/Thrashattack/chronicle)
5
+ [![Gem Version](https://img.shields.io/gem/v/chronicle.svg)](https://rubygems.org/gems/chronicle)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.4.5-CC342D.svg)](https://www.ruby-lang.org/)
7
+
8
+ Chronicle connects Datomic to Ruby on Rails Active Record. It maps Active Record models to Datomic facts and exposes immutable history through normal Rails query patterns. Check [Datomic](https://www.datomic.com/) page
9
+
10
+ ## Requirements
11
+
12
+ - Ruby 3.4.5 or newer.
13
+ - Rails Active Record 7.0 or newer.
14
+ - Datomic Pro for the Docker examples and JRuby integrations.
15
+ - Java 17 for the Datomic container.
16
+ - Java 21 for JRuby 10 workloads.
17
+
18
+ ## Capabilities
19
+
20
+ - Active Record integration through `Chronicle::Model`.
21
+ - Datomic attributes with types and schema options.
22
+ - Datalog compilation for equality, comparison, `IN`, projection, and pull queries.
23
+ - Association preloading through Datomic pull results.
24
+ - Time travel with `.as_of` and `.since`.
25
+ - Datomic schema and migration helpers.
26
+ - Cross-database transactions with compensating Datomic retractions.
27
+ - Retry handling with exponential backoff and jitter.
28
+ - Circuit breaker support.
29
+ - Fiber-aware backoff when a Ruby scheduler is active.
30
+ - CRuby transport through Datomic REST using EDN.
31
+ - JRuby Client API transport through a Datomic peer server.
32
+ - JRuby Peer API transport with direct access to the Datomic transactor.
33
+ - Rails initializer and migration generators.
34
+
35
+ ## Installation
36
+
37
+ Add Active Chronicle to the application Gemfile:
38
+
39
+ ```ruby
40
+ gem "active_chronicle"
41
+ ```
42
+
43
+ Then install the bundle:
44
+
45
+ ```bash
46
+ bundle install
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ Generate an initializer:
52
+
53
+ ```bash
54
+ bin/rails generate chronicle:initializer
55
+ ```
56
+
57
+ Configure a Datomic connection in `config/database.yml`.
58
+
59
+ ### CRuby and REST
60
+
61
+ Chronicle's CRuby transport uses Datomic's REST service. Datomic marks REST as a legacy interface, but it remains useful for existing integrations and supports the Ruby transport.
62
+
63
+ ```yaml
64
+ datomic:
65
+ adapter: datomic
66
+ uri: datomic:dev://localhost:4334/app_dev
67
+ client_endpoint: https://localhost:8001
68
+ rest: true
69
+ ```
70
+
71
+ ### JRuby Client API
72
+
73
+ The Client API connects to a Datomic peer server. It requires the peer-server endpoint, access key, secret, and database name.
74
+
75
+ ```yaml
76
+ datomic:
77
+ adapter: datomic
78
+ uri: datomic:dev://localhost:4334/app_dev
79
+ client_endpoint: localhost:8998
80
+ access_key: chronicle-dev
81
+ secret: chronicle-dev-secret
82
+ ```
83
+
84
+ ### JRuby Peer API
85
+
86
+ The Peer API connects directly to the transactor. Select it with `transport: peer`.
87
+
88
+ ```yaml
89
+ datomic:
90
+ adapter: datomic
91
+ uri: datomic:dev://localhost:4334/app_dev
92
+ transport: peer
93
+ ```
94
+
95
+ The included Compose examples mount the Datomic distribution at `/opt/datomic`.
96
+ Chronicle loads `peer-*.jar` from the distribution root and its dependencies from
97
+ `lib/*.jar`. Set `DATOMIC_HOME` when the distribution is installed elsewhere.
98
+ The Peer jar is not inside `lib`: for Datomic Pro 1.0.7705 it is
99
+ `/opt/datomic/peer-1.0.7705.jar`, corresponding to Maven artifact
100
+ `com.datomic:peer:1.0.7705`. Alternatively, resolve that artifact and its runtime
101
+ dependencies onto the application JVM classpath. Startup raises an error if
102
+ `datomic.Peer` cannot be resolved or `Peer.connect` returns no connection.
103
+
104
+ ## Model Integration
105
+
106
+ Include `Chronicle::Model` and declare the attributes stored in Datomic:
107
+
108
+ ```ruby
109
+ class DatomicRecord < ApplicationRecord
110
+ self.abstract_class = true
111
+
112
+ connects_to database: { writing: :datomic, reading: :datomic }
113
+ end
114
+
115
+ class HistoricalRecord < DatomicRecord
116
+ include Chronicle::Model
117
+
118
+ datomic_attribute :event_name, :string
119
+ datomic_attribute :user_id, :integer, index: true
120
+ datomic_attribute :payload, :string
121
+ end
122
+ ```
123
+
124
+ `connects_to` must be declared on an abstract Active Record class. Rails 8 rejects it on a concrete model. Keep SQLite-backed models on `ApplicationRecord` and inherit Datomic-backed models from the abstract Datomic base.
125
+
126
+ `Chronicle::Model` also provides `to_datoms`, `datomic_entity_id`, and model-level `.as_of` and `.since` query entry points.
127
+
128
+ ## Schema and Migrations
129
+
130
+ Generate a Datomic migration:
131
+
132
+ ```bash
133
+ bin/rails generate chronicle:migration create_historical_records \
134
+ event_name:string user_id:integer:index
135
+ ```
136
+
137
+ A generated migration uses the Chronicle table definition:
138
+
139
+ ```ruby
140
+ class CreateHistoricalRecords < ActiveRecord::Migration[8.1]
141
+ def change
142
+ create_datomic_schema :historical_record do |table|
143
+ table.string :event_name
144
+ table.integer :user_id, index: true
145
+ table.timestamps
146
+ end
147
+ end
148
+ end
149
+ ```
150
+
151
+ Run it with:
152
+
153
+ ```bash
154
+ bin/rails db:migrate
155
+ ```
156
+
157
+ ## Time Travel
158
+
159
+ Datomic never overwrites a fact. Each transaction produces a new database value and a transaction time. Chronicle exposes that history through relation scopes:
160
+
161
+ ```ruby
162
+ past = HistoricalRecord.as_of(2.hours.ago).where(user_id: 42)
163
+ recent = HistoricalRecord.since(10040).where(event_name: "login")
164
+ ```
165
+
166
+ The transport applies `as_of` and `since` to the Datomic database snapshot before it executes the query.
167
+
168
+ ## Cross-Database Transactions
169
+
170
+ `Chronicle::TransactionCoordinator` coordinates a Datomic write and a relational write. If the relational operation fails, it sends compensating retractions to Datomic and raises `Chronicle::TransactionError`.
171
+
172
+ ```ruby
173
+ Chronicle::TransactionCoordinator.transaction do |transaction|
174
+ transaction.datomic(record.to_datoms)
175
+ transaction.postgres do
176
+ AuditLog.create!(datomic_basis_t: transaction.basis_t)
177
+ end
178
+ end
179
+ ```
180
+
181
+ ## Examples
182
+
183
+ The repository contains three Rails applications. They are excluded from the published gem.
184
+
185
+ | Example | Ruby | Datomic API | Port | Purpose |
186
+ | --- | --- | --- | ---: | --- |
187
+ | `news_feed` | CRuby | REST | 3001 | Publish stories and inspect revision history. |
188
+ | `wallet` | JRuby 10 | Client API | 3000 | Record deposits and withdrawals over time. |
189
+ | `animal_tracker` | JRuby 10 | Peer API | 3002 | Record coordinates and draw the historical path on a map. |
190
+ | `cross_store` | CRuby | REST + SQLite | 3003 | Reference a Datomic customer from a SQLite purchase and benchmark coordinated writes. |
191
+
192
+ The Compose stack downloads and installs Datomic inside the Datomic container. It runs the transactor, peer server, REST service, and all four applications:
193
+
194
+ ```bash
195
+ cd examples
196
+ docker compose up --build
197
+ ```
198
+
199
+ Then open:
200
+
201
+ - `http://localhost:3000` for the wallet.
202
+ - `http://localhost:3001` for the news feed.
203
+ - `http://localhost:3002` for the animal tracker.
204
+ - `http://localhost:3003` for the cross-store example.
205
+
206
+ The Datomic peer server listens on port `8998`. The REST service listens on port `8001`. The transactor uses ports `4334` and `4335`.
207
+
208
+ Run the cross-store benchmark with:
209
+
210
+ ```bash
211
+ docker compose -f examples/docker-compose.yml exec cross_store \
212
+ bundle exec ruby benchmark/cross_store_benchmark.rb
213
+ ```
214
+
215
+ ## Development
216
+
217
+ Run the full local validation:
218
+
219
+ ```bash
220
+ bundle exec rake quality
221
+ ```
222
+
223
+ This runs RuboCop and RSpec. The tracked pre-commit hook runs the same checks:
224
+
225
+ ```bash
226
+ git config core.hooksPath .githooks
227
+ ```
228
+
229
+ The CI workflow runs RuboCop, RSpec, and uploads SimpleCov results to Codecov.
230
+
231
+ ## Release Contents
232
+
233
+ The gem contains only `lib/`, `README.md`, `CHANGELOG.md`, and `LICENSE.txt`. It excludes specs, CI configuration, examples, the Gemfile, the Rakefile, and the gemspec.
234
+
235
+ Build the package with:
236
+
237
+ ```bash
238
+ gem build chronicle.gemspec
239
+ ```
240
+
241
+ ## Support Notes
242
+
243
+ - The CRuby REST transport and the JRuby Client API transport use different Datomic endpoints.
244
+ - The JRuby Peer API requires the Datomic distribution jars and a JVM.
245
+ - The Docker examples use Datomic Pro distribution downloads. Review Datomic licensing and distribution terms before use.
246
+ - JRuby and Datomic containers are not required to run the CRuby unit test suite.
247
+
248
+ ## Special Thanks
249
+
250
+ - Thanks to the creators of [Diametric](https://github.com/relevance/diametric), it was the spark of inspiration for this project.
251
+
252
+ ## License
253
+
254
+ Chronicle is available under the MIT License.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support/all'
5
+
6
+ require_relative 'chronicle/version'
7
+ require_relative 'chronicle/resilience'
8
+ require_relative 'chronicle/transport'
9
+ require_relative 'chronicle/connection_adapters/datomic_adapter'
10
+ require_relative 'chronicle/datalog/compiler'
11
+ require_relative 'chronicle/datalog/optimizer'
12
+ require_relative 'chronicle/relation'
13
+ require_relative 'chronicle/hydrator'
14
+ require_relative 'chronicle/model'
15
+ require_relative 'chronicle/schema'
16
+ require_relative 'chronicle/transaction_coordinator'
17
+
18
+ require_relative 'chronicle/railtie' if defined?(Rails::Railtie)
19
+
20
+ module Chronicle
21
+ class Error < StandardError; end
22
+ class ConnectionError < Error; end
23
+ class TransactionError < Error; end
24
+ class SchemaError < Error; end
25
+ class CircuitBreakerError < Error; end
26
+
27
+ class << self
28
+ attr_accessor :configuration
29
+
30
+ def configure
31
+ self.configuration ||= Configuration.new
32
+ yield(configuration) if block_given?
33
+ end
34
+
35
+ def reset_configuration!
36
+ self.configuration = Configuration.new
37
+ end
38
+ end
39
+
40
+ class Configuration
41
+ attr_accessor :uri, :client_endpoint, :secret, :pool_size,
42
+ :max_retries, :retry_base_delay, :retry_max_delay,
43
+ :circuit_breaker_threshold, :circuit_breaker_cooldown
44
+
45
+ def initialize
46
+ @pool_size = 5
47
+ @max_retries = 5
48
+ @retry_base_delay = 0.1
49
+ @retry_max_delay = 2.0
50
+ @circuit_breaker_threshold = 5
51
+ @circuit_breaker_cooldown = 10.0
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/abstract_adapter'
4
+
5
+ module ActiveRecord
6
+ module ConnectionAdapters
7
+ class DatomicAdapter < AbstractAdapter
8
+ ADAPTER_NAME = 'Datomic'
9
+
10
+ class << self
11
+ def quote_column_name(name)
12
+ "\"#{name.to_s.gsub('"', '""')}\""
13
+ end
14
+
15
+ def quote_table_name(name)
16
+ name.to_s.split('.').map { |part| quote_column_name(part) }.join('.')
17
+ end
18
+ end
19
+
20
+ attr_reader :transport
21
+
22
+ def initialize(config = {})
23
+ super
24
+ @config = config
25
+ @transport = Chronicle::Transport.build(config)
26
+ @transport.connect!
27
+ end
28
+
29
+ def adapter_name
30
+ ADAPTER_NAME
31
+ end
32
+
33
+ def quote_column_name(name)
34
+ "\"#{name.to_s.gsub('"', '""')}\""
35
+ end
36
+
37
+ def quote_table_name(name)
38
+ name.to_s.split('.').map { |part| quote_column_name(part) }.join('.')
39
+ end
40
+
41
+ def supports_migrations?
42
+ true
43
+ end
44
+
45
+ def supports_primary_key?
46
+ true
47
+ end
48
+
49
+ def supports_ddl_transactions?
50
+ false
51
+ end
52
+
53
+ def tables(_name = nil)
54
+ datomic_models.map(&:table_name).uniq
55
+ end
56
+
57
+ alias data_sources tables
58
+
59
+ def data_source_exists?(name)
60
+ tables.include?(name.to_s)
61
+ end
62
+
63
+ alias table_exists? data_source_exists?
64
+
65
+ def primary_keys(table_name)
66
+ data_source_exists?(table_name) ? ['id'] : []
67
+ end
68
+
69
+ def column_definitions(table_name)
70
+ model = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name.to_s }
71
+ attributes = model.respond_to?(:datomic_attributes) ? model.datomic_attributes : {}
72
+ attributes.keys.unshift(:id).uniq.map do |name|
73
+ type = name == :id ? :bigint : attributes.dig(name, :type)
74
+ type = :datetime if type == :instant
75
+ column_type = %i[integer bigint].include?(type) ? ActiveRecord::Type.lookup(:integer, limit: 8) : ActiveRecord::Type.lookup(type || :value)
76
+ ActiveRecord::ConnectionAdapters::Column.new(name.to_s, column_type, nil)
77
+ end
78
+ end
79
+
80
+ def new_column_from_field(_table_name, field, _definitions)
81
+ return field if field.is_a?(ActiveRecord::ConnectionAdapters::Column)
82
+
83
+ ActiveRecord::ConnectionAdapters::Column.new(field.to_s, ActiveRecord::Type::Value.new, nil)
84
+ end
85
+
86
+ # Executes a Datalog query or raw datom query
87
+ def execute(query, name = nil)
88
+ log(query.to_s, name || 'DATOMIC') do
89
+ db_snapshot = @transport.db
90
+ @transport.q(query, db_snapshot)
91
+ end
92
+ end
93
+
94
+ # Standard Active Record exec_query interface
95
+ def exec_query(query, name = 'DATOMIC', binds = [], prepare: false, async: false, time_travel: nil, **_options)
96
+ log(query.to_s, name) do
97
+ db_snapshot = @transport.db(
98
+ as_of: time_travel&.dig(:as_of),
99
+ since: time_travel&.dig(:since)
100
+ )
101
+
102
+ raw_results = @transport.q(query, db_snapshot, *binds)
103
+ format_results(raw_results)
104
+ end
105
+ end
106
+
107
+ # High-level query interface returning ActiveRecord::Result
108
+ def select_all(relation_or_query, name = nil, binds = [], prepare: false, async: false, time_travel: nil, **_options)
109
+ if relation_or_query.respond_to?(:to_datalog)
110
+ query_data = relation_or_query.to_datalog
111
+ exec_query(query_data[:query], name, query_data[:bindings], time_travel:)
112
+ elsif relation_or_query.is_a?(Hash) && relation_or_query[:query]
113
+ exec_query(relation_or_query[:query], name, relation_or_query[:bindings] || [], time_travel:)
114
+ else
115
+ exec_query(relation_or_query, name, binds, time_travel:)
116
+ end
117
+ end
118
+
119
+ def ensure_model_schema(model_class)
120
+ return if !model_class.respond_to?(:datomic_attributes) || model_class.datomic_attributes.empty?
121
+
122
+ namespace = model_class.table_name.singularize
123
+ attributes = model_class.datomic_attributes.dup
124
+ if model_class.respond_to?(:record_timestamps) && model_class.record_timestamps
125
+ attributes[:created_at] ||= { type: :instant, options: {} }
126
+ attributes[:updated_at] ||= { type: :instant, options: {} }
127
+ end
128
+ schema_datoms = attributes.map do |attribute, definition|
129
+ type = datomic_type(definition[:type])
130
+ raise Chronicle::SchemaError, "Unsupported Datomic attribute type: #{definition[:type]}" unless type
131
+
132
+ options = definition[:options] || {}
133
+ datom = Chronicle::Schema.create_attribute(namespace, attribute, type, options)
134
+ datom[':db/index'] = true if options[:index]
135
+ datom[':db/unique'] = ":db.unique/#{options[:unique]}" if %i[identity value].include?(options[:unique])
136
+ datom.transform_values { |value| value.is_a?(String) && value.start_with?(':') ? value.to_sym : value }
137
+ end
138
+ @transport.transact(schema_datoms)
139
+ end
140
+
141
+ # Transacts new or updated entities into Datomic
142
+ def insert(model_or_statement, attributes_or_name = nil, *args, **options)
143
+ if model_or_statement.is_a?(Arel::InsertManager)
144
+ statement = model_or_statement.ast
145
+ table_name = statement.relation.name
146
+ model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
147
+ values = statement.values.expr.first
148
+ attributes = statement.columns.zip(values).to_h do |column, value|
149
+ normalized_value = value.respond_to?(:value) ? value.value : value
150
+ normalized_value = nil if normalized_value.is_a?(ActiveModel::Type::Value)
151
+ [column.name, normalized_value]
152
+ end
153
+ entity_id = insert_entity(model_class, attributes)
154
+ returning = options[:returning]
155
+ primary_key = args[0]
156
+ return returning.map { |column| column.to_s == primary_key.to_s ? entity_id : attributes[column.to_s] } if returning
157
+
158
+ return entity_id
159
+ end
160
+
161
+ insert_entity(model_or_statement, attributes_or_name)
162
+ end
163
+
164
+ def insert_entity(model_class, attributes)
165
+ ensure_model_schema(model_class)
166
+ namespace = model_class.table_name.singularize
167
+ temp_id = attributes['id'] || attributes[:id] || 'chronicle_temp_id'
168
+
169
+ datoms = attributes.map do |k, v|
170
+ next if k.to_s == 'id' || v.nil?
171
+
172
+ [:'db/add', temp_id, :":#{namespace}/#{k}", v]
173
+ end.compact
174
+
175
+ tx_result = @transport.transact(datoms)
176
+ tempids = tx_result&.dig(:tempids) || tx_result&.dig('tempids') || {}
177
+ tempids[temp_id] || tempids[temp_id.to_sym] || temp_id
178
+ end
179
+
180
+ # Updates existing entity attributes
181
+ def update(model_or_statement, entity_id_or_name = nil, attributes = nil)
182
+ if model_or_statement.is_a?(Arel::UpdateManager)
183
+ statement = model_or_statement.ast
184
+ table_name = statement.relation.name
185
+ model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
186
+ entity_id = statement.wheres.filter_map do |predicate|
187
+ next unless predicate.is_a?(Arel::Nodes::Equality) && predicate.left.name.to_s == 'id'
188
+
189
+ value = predicate.right
190
+ value.respond_to?(:value) ? value.value : value
191
+ end.first
192
+ values = statement.values.to_h do |assignment|
193
+ [assignment.left.expr.name, assignment.right.value]
194
+ end
195
+ update_entity(model_class, entity_id, values)
196
+ return 1
197
+ end
198
+
199
+ update_entity(model_or_statement, entity_id_or_name, attributes)
200
+ end
201
+
202
+ def update_entity(model_class, entity_id, attributes)
203
+ ensure_model_schema(model_class)
204
+ namespace = model_class.table_name.singularize
205
+
206
+ datoms = attributes.map do |k, v|
207
+ next if k.to_s == 'id'
208
+
209
+ [:'db/add', entity_id, :":#{namespace}/#{k}", v]
210
+ end.compact
211
+
212
+ @transport.transact(datoms)
213
+ end
214
+
215
+ # Retracts an entity entirely from current database state
216
+ def delete(statement_or_entity_id, *_args)
217
+ if statement_or_entity_id.is_a?(Arel::DeleteManager)
218
+ statement = statement_or_entity_id.ast
219
+ table_name = statement.relation.name
220
+ model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
221
+ entity_id = statement.wheres.filter_map do |predicate|
222
+ next unless predicate.is_a?(Arel::Nodes::Equality) && predicate.left.name.to_s == 'id'
223
+
224
+ value = predicate.right
225
+ value.respond_to?(:value) ? value.value : value
226
+ end.first
227
+ return delete_entity(model_class, entity_id)
228
+ end
229
+
230
+ delete_entity(nil, statement_or_entity_id)
231
+ end
232
+
233
+ def delete_entity(_model_class, entity_id)
234
+ datoms = [[:'db/retractEntity', entity_id]]
235
+ @transport.transact(datoms)
236
+ 1
237
+ end
238
+
239
+ def active?
240
+ @transport.connected?
241
+ end
242
+
243
+ def reconnect!
244
+ @transport.connect!
245
+ end
246
+
247
+ def disconnect!
248
+ @transport.disconnect!
249
+ end
250
+
251
+ private
252
+
253
+ def datomic_models
254
+ ActiveRecord::Base.descendants.reject(&:abstract_class?)
255
+ end
256
+
257
+ def datomic_type(type)
258
+ {
259
+ string: 'string', integer: 'long', bigint: 'long', float: 'double', boolean: 'boolean',
260
+ instant: 'instant', datetime: 'instant', ref: 'ref', uuid: 'uuid', bytes: 'bytes', uri: 'uri'
261
+ }[type.to_sym]
262
+ end
263
+
264
+ def format_results(raw_results)
265
+ return ActiveRecord::Result.new([], []) if raw_results.blank?
266
+
267
+ if raw_results.first.is_a?(Array)
268
+ cols = (0...raw_results.first.size).map { |i| "col_#{i}" }
269
+ ActiveRecord::Result.new(cols, raw_results)
270
+ elsif raw_results.first.is_a?(Hash)
271
+ cols = raw_results.first.keys.map(&:to_s)
272
+ rows = raw_results.map(&:values)
273
+ ActiveRecord::Result.new(cols, rows)
274
+ else
275
+ ActiveRecord::Result.new(['value'], raw_results.map { |v| [v] })
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ # Register adapter with ActiveRecord
283
+ if defined?(ActiveRecord::ConnectionAdapters) && ActiveRecord::ConnectionAdapters.respond_to?(:register)
284
+ ActiveRecord::ConnectionAdapters.register('datomic', 'ActiveRecord::ConnectionAdapters::DatomicAdapter',
285
+ 'chronicle/connection_adapters/datomic_adapter')
286
+ end