action_merge 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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +157 -0
  3. data/README.md +49 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/builds/action_merge/application.d.ts +1 -0
  6. data/app/assets/builds/action_merge/application.d.ts.map +1 -0
  7. data/app/assets/builds/action_merge/application.js +6 -0
  8. data/app/assets/builds/action_merge/application.js.map +1 -0
  9. data/app/assets/builds/action_merge/automerge.wasm +0 -0
  10. data/app/assets/builds/action_merge/network-adapter.d.ts +18 -0
  11. data/app/assets/builds/action_merge/network-adapter.d.ts.map +1 -0
  12. data/app/assets/builds/action_merge/syncable.d.ts +7 -0
  13. data/app/assets/builds/action_merge/syncable.d.ts.map +1 -0
  14. data/app/assets/config/action_merge_manifest.js +1 -0
  15. data/app/assets/stylesheets/action_merge/application.css +15 -0
  16. data/app/channels/action_merge/sync_channel.rb +36 -0
  17. data/app/channels/application_cable/channel.rb +4 -0
  18. data/app/channels/application_cable/connection.rb +4 -0
  19. data/app/controllers/action_merge/application_controller.rb +4 -0
  20. data/app/helpers/action_merge/application_helper.rb +4 -0
  21. data/app/javascript/action_merge/application.ts +5 -0
  22. data/app/javascript/action_merge/network-adapter.ts +144 -0
  23. data/app/javascript/action_merge/syncable.ts +24 -0
  24. data/app/jobs/action_merge/application_job.rb +4 -0
  25. data/app/mailers/action_merge/application_mailer.rb +6 -0
  26. data/app/models/action_merge/application_record.rb +5 -0
  27. data/app/models/action_merge/document/chunk.rb +11 -0
  28. data/app/models/action_merge/document.rb +6 -0
  29. data/app/models/action_merge/peer.rb +23 -0
  30. data/app/models/action_merge/storage_adapter.rb +32 -0
  31. data/app/views/layouts/action_merge/application.html.erb +14 -0
  32. data/config/routes.rb +2 -0
  33. data/db/migrate/20240910160133_create_action_merge_documents.rb +19 -0
  34. data/lib/action_merge/assets.rb +19 -0
  35. data/lib/action_merge/engine.rb +16 -0
  36. data/lib/action_merge/helpers.rb +7 -0
  37. data/lib/action_merge/syncable.rb +19 -0
  38. data/lib/action_merge.rb +12 -0
  39. data/lib/tasks/action_merge_tasks.rake +4 -0
  40. metadata +104 -0
@@ -0,0 +1,18 @@
1
+ import { Consumer, Subscription } from '@rails/actioncable';
2
+ import { NetworkAdapter, PeerId, PeerMetadata, Message } from '@automerge/automerge-repo/slim';
3
+ declare class ActionCableNetworkAdapter extends NetworkAdapter {
4
+ #private;
5
+ channelName: string;
6
+ consumer: Consumer;
7
+ subscription?: Subscription;
8
+ syncableType: string;
9
+ syncableId: any;
10
+ constructor(channelName: string, syncableType: string, syncableId: any, serverUrl?: string);
11
+ connect(peerId: PeerId, peerMetadata?: PeerMetadata): void;
12
+ disconnect(): void;
13
+ send(message: Message): void;
14
+ whenReady(): Promise<void>;
15
+ isReady(): boolean;
16
+ }
17
+ export default ActionCableNetworkAdapter;
18
+ //# sourceMappingURL=network-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network-adapter.d.ts","sourceRoot":"","sources":["../../../javascript/action_merge/network-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,QAAQ,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAQ,MAAM,gCAAgC,CAAA;AAGpG,cAAM,yBAA0B,SAAQ,cAAc;;IACrD,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,QAAQ,CAAA;IAElB,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,GAAG,CAAA;gBACH,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,CAAC,EAAE,MAAM;IAS1F,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,YAAY;IAyEnD,UAAU;IAeV,IAAI,CAAC,OAAO,EAAE,OAAO;IAmBrB,SAAS;IAWT,OAAO;CAGP;AAED,eAAe,yBAAyB,CAAA"}
@@ -0,0 +1,7 @@
1
+ declare global {
2
+ interface Window {
3
+ Syncable?: any;
4
+ }
5
+ }
6
+ export {};
7
+ //# sourceMappingURL=syncable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"syncable.d.ts","sourceRoot":"","sources":["../../../javascript/action_merge/syncable.ts"],"names":[],"mappings":"AAIA,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,MAAM;QAAG,QAAQ,CAAC,EAAE,GAAG,CAAA;KAAE;CACnC"}
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/action_merge .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,36 @@
1
+ require "active_support/all"
2
+
3
+ module ActionMerge
4
+ class SyncChannel < ActionCable::Channel::Base
5
+ cattr_accessor :peers
6
+ @@peers ||= {}
7
+
8
+ def subscribed
9
+ @model = ActiveSupport::Inflector.constantize(params[:syncable_type]).find(params[:syncable_id])
10
+
11
+ reject unless @model.authorize_sync(self)
12
+
13
+ @peer = ActionMerge::Peer.new(params[:id], storageId: params.dig(:metadata, :storageId), isEphemeral: params.dig(:metadata, :isEphemeral), model: @model)
14
+
15
+ @@peers[@peer.to_gid_param] = @peer
16
+
17
+ stream_for @peer
18
+ end
19
+
20
+ def perform_action(data)
21
+ data.deep_symbolize_keys!
22
+ if data.dig(:targetId).presence
23
+ self.class.broadcast_to(@@peers[data[:targetId]], data)
24
+ else
25
+ @@peers.each do |id, peer|
26
+ self.class.broadcast_to(peer, data) unless id == @peer&.to_gid_param
27
+ end
28
+ end
29
+ end
30
+
31
+ def unsubscribed
32
+ @@peers[@peer&.to_gid_param] = nil
33
+ @peer = nil
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Channel < ActionCable::Channel::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Connection < ActionCable::Connection::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ActionMerge
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ActionMerge
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ import "./syncable.js"
2
+ import { next as Am } from "@automerge/automerge"
3
+
4
+ // @ts-ignore
5
+ window.Am = Am
@@ -0,0 +1,144 @@
1
+ import { createConsumer, Consumer, Subscription } from "@rails/actioncable"
2
+ import { NetworkAdapter, PeerId, PeerMetadata, Message, cbor } from "@automerge/automerge-repo/slim"
3
+ import omit from "lodash.omit"
4
+
5
+ class ActionCableNetworkAdapter extends NetworkAdapter {
6
+ channelName: string
7
+ consumer: Consumer
8
+ #isReady: boolean
9
+ subscription?: Subscription
10
+ syncableType: string
11
+ syncableId: any
12
+ constructor(channelName: string, syncableType: string, syncableId: any, serverUrl?: string) {
13
+ super()
14
+ this.channelName = channelName
15
+ this.syncableType = syncableType
16
+ this.syncableId = syncableId
17
+ this.consumer = createConsumer(serverUrl)
18
+ this.#isReady = false
19
+ }
20
+
21
+ connect(peerId: PeerId, peerMetadata?: PeerMetadata) {
22
+ this.peerId = peerId
23
+ this.peerMetadata = peerMetadata
24
+
25
+ this.subscription = this.consumer.subscriptions.create({ channel: this.channelName, id: this.peerId, metadata: this.peerMetadata || {}, syncable_id: this.syncableId, syncable_type: this.syncableType }, {
26
+ connected: () => {
27
+ this.#isReady = true
28
+ this.send({
29
+ // @ts-expect-error
30
+ senderId: this.peerId,
31
+ type: "arrive",
32
+ // @ts-expect-error
33
+ targetId: null
34
+ })
35
+ },
36
+
37
+ disconnected: () => {
38
+ this.#isReady = false
39
+ this.emit("close")
40
+ },
41
+
42
+ received: (message) => {
43
+ // @ts-ignore
44
+ window.message = message
45
+ if (message.targetId != null && message.targetId !== this.peerId) {
46
+ if (message.senderId == this.peerId) {
47
+ return
48
+ }
49
+ throw new Error(
50
+ "ActionCableNetworkAdapter should never receive messages for a different peer."
51
+ )
52
+ }
53
+
54
+ const { senderId, type } = message
55
+
56
+ switch (type) {
57
+ case "arrive":
58
+ {
59
+ const { peerMetadata } = message
60
+ this.send({
61
+ type: "welcome",
62
+ // @ts-expect-error
63
+ senderId: this.peerId,
64
+ peerMetadata: this.peerMetadata,
65
+ targetId: senderId,
66
+ })
67
+ this.emit("peer-candidate", { peerId: senderId, peerMetadata })
68
+ }
69
+ break
70
+ case "welcome":
71
+ {
72
+ const { peerMetadata } = message
73
+ this.emit("peer-candidate", { peerId: senderId, peerMetadata })
74
+ }
75
+ break
76
+ case "leave":
77
+ this.emit("peer-disconnected", { peerId: senderId })
78
+ default:
79
+ if(message.data && Object.values(message.data).length > 0) {
80
+ // @ts-ignore
81
+ this.emit("message", {
82
+ ...omit(message, "data"),
83
+ data: new Uint8Array(Object.values(message.data)),
84
+ })
85
+ } else {
86
+ this.emit("message", message)
87
+ }
88
+ break
89
+ }
90
+ },
91
+ })
92
+ }
93
+
94
+ disconnect() {
95
+ if (this.subscription) {
96
+ this.send({
97
+ type: "leave",
98
+ // @ts-expect-error
99
+ senderId: this.peerId,
100
+ })
101
+ // @ts-expect-error
102
+ this.emit("peer-disconnected", { peerId: this.peerId })
103
+ this.subscription.unsubscribe()
104
+ this.subscription = undefined
105
+ }
106
+ }
107
+
108
+
109
+ send(message: Message) {
110
+ this.subscription?.send(message)
111
+ if (message.data) {
112
+ const data = message.data.buffer.slice(
113
+ message.data.byteOffset,
114
+ message.data.byteOffset + message.data.byteLength
115
+ )
116
+
117
+ this.subscription?.send(
118
+ {
119
+ ...message,
120
+ data,
121
+ }
122
+ )
123
+ } else {
124
+ this.subscription?.send(message)
125
+ }
126
+ }
127
+
128
+ whenReady() {
129
+ return new Promise<void>((resolve) => {
130
+ while (true) {
131
+ if (this.#isReady) {
132
+ break
133
+ }
134
+ }
135
+ resolve()
136
+ })
137
+ }
138
+
139
+ isReady() {
140
+ return this.#isReady
141
+ }
142
+ }
143
+
144
+ export default ActionCableNetworkAdapter
@@ -0,0 +1,24 @@
1
+ import { Repo, type RepoConfig } from "@automerge/automerge-repo";
2
+ import ActionCableNetworkAdapter from "./network-adapter.js"
3
+ import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
4
+
5
+ declare global {
6
+ interface Window { Syncable?: any }
7
+ }
8
+
9
+ type Syncable = {
10
+ new (klass: string, id: any, channel?: string, obj?: RepoConfig): Repo
11
+ }
12
+
13
+ const Syncable: Syncable = new Proxy(Repo, {
14
+ construct: (target, args) => {
15
+ const [ klass, id, channel = "ActionMerge::SyncChannel", obj = {} ] = args
16
+ return new target({
17
+ network: [new ActionCableNetworkAdapter(channel, klass, id)],
18
+ storage: new IndexedDBStorageAdapter(),
19
+ ...obj
20
+ });
21
+ }
22
+ }) as Syncable
23
+
24
+ window.Syncable = Syncable
@@ -0,0 +1,4 @@
1
+ module ActionMerge
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module ActionMerge
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module ActionMerge
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module ActionMerge
2
+ class Document::Chunk < ApplicationRecord
3
+ belongs_to :action_merge_document
4
+
5
+ def upsert(k,v)
6
+ c = chunks.find(k).presence || chunks.new(primary_key => k)
7
+ c.update!(v)
8
+ c.save
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ module ActionMerge
2
+ class Document < ApplicationRecord
3
+ belongs_to :syncable, polymorphic: true
4
+ has_many :action_merge_document_chunks, as: :chunks, foreign_key: :document_id
5
+ end
6
+ end
@@ -0,0 +1,23 @@
1
+ module ActionMerge
2
+ class Peer
3
+ attr_accessor :id, :isEphemeral, :storageId, :model
4
+
5
+ def initialize(id, isEphemeral: nil, storageId: nil, model: nil)
6
+ @id = id
7
+ @isEphemeral = isEphemeral
8
+ @storageId = storageId
9
+ @model = model
10
+ end
11
+
12
+ def metadata
13
+ {
14
+ isEphemeral:,
15
+ storageId:
16
+ }
17
+ end
18
+
19
+ def to_gid_param
20
+ [[@model.class.name, @model.id], id]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # @implements automerge.org/automerge-repo/interfaces/_automerge_automerge_repo.StorageAdapterInterface
2
+ module ActionMerge
3
+ module StorageAdapter
4
+ def load(key)
5
+ ActionMerge::Document::Chunk.find(key)
6
+ end
7
+
8
+ def loadRange(key)
9
+ document_id, type = *key
10
+
11
+ d = ActionMerge::Document.find(document_id)
12
+
13
+ if type
14
+ d.chunks.where(type:)
15
+ else
16
+ d.chunks
17
+ end
18
+ end
19
+
20
+ def remove(key)
21
+ ActionMerge::Document::Chunk.destroy(key)
22
+ end
23
+
24
+ def removeRange(key)
25
+ loadRange(key).destroy_all
26
+ end
27
+
28
+ def save(key, value)
29
+ ActionMerge::Document::Chunk.upsert(key, value)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Action Merge</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "action_merge/application", media: "all" %>
9
+ <%= javascript_include_tag "action_merge/application", "data-turbo-track": "reload", type: "module" %>
10
+ </head>
11
+ <body>
12
+ <%= yield %>
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ ActionMerge::Engine.routes.draw do
2
+ end
@@ -0,0 +1,19 @@
1
+ class CreateActionMergeDocuments < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :action_merge_documents, primary_key: :id, id: false do |t|
4
+ t.string :id
5
+ t.string :syncable_type
6
+ t.integer :syncable_id
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ create_table :action_merge_document_chunks, primary_key: [:action_merge_document_id, :type, :hash], id: false do |t|
12
+ t.string :type
13
+ t.text :hash
14
+ t.belongs_to :action_merge_document
15
+
16
+ t.timestamps
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module ActionMerge
2
+ class Assets
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ path = env[Rack::PATH_INFO]
9
+
10
+ if path.start_with? "/.well-known/action_merge/assets/"
11
+ env[Rack::PATH_INFO] = path.split("/").last
12
+
13
+ Rack::Files.new(ActionMerge::Engine.root.join("app", "assets", "builds", "action_merge")).call(env)
14
+ else
15
+ @app.call(env)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ module ActionMerge
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ActionMerge
4
+
5
+ initializer "action_merge.assets" do |app|
6
+ app.middleware.insert_after(
7
+ Rack::Sendfile,
8
+ ActionMerge::Assets
9
+ )
10
+ end
11
+
12
+ initializer "action_merge.helpers" do
13
+ ActiveSupport.on_load(:action_view) { include ActionMerge::Helpers }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ module ActionMerge
2
+ module Helpers
3
+ def action_merge_javascript_tag
4
+ ActiveSupport::SafeBuffer.new '<script src="/.well-known/action_merge/assets/application.js" data-turbo-track="reload" type="module"></script>'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ module ActionMerge
2
+ module Syncable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_one :automerge_document, dependent: :destroy, as: :syncable
7
+ end
8
+
9
+ class_methods do
10
+ def to_authorize_sync(&block)
11
+ @@_to_authorize_sync = block
12
+ end
13
+ end
14
+
15
+ def authorize_sync(channel)
16
+ channel.instance_eval &@@_to_authorize_sync
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ require "zeitwerk"
2
+ loader = Zeitwerk::Loader.for_gem
3
+ loader.setup
4
+
5
+ module ActionMerge
6
+ def self.table_name_prefix
7
+ "action_merge_"
8
+ end
9
+ end
10
+
11
+ loader.eager_load
12
+
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :action_merge do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_merge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Reese Armstrong
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-09-16 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: '7.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: '8.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ description: Build local-first and collaborative Rails applications with Automerge
34
+ email:
35
+ - me@reeseric.ci
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - LICENSE.md
41
+ - README.md
42
+ - Rakefile
43
+ - app/assets/builds/action_merge/application.d.ts
44
+ - app/assets/builds/action_merge/application.d.ts.map
45
+ - app/assets/builds/action_merge/application.js
46
+ - app/assets/builds/action_merge/application.js.map
47
+ - app/assets/builds/action_merge/automerge.wasm
48
+ - app/assets/builds/action_merge/network-adapter.d.ts
49
+ - app/assets/builds/action_merge/network-adapter.d.ts.map
50
+ - app/assets/builds/action_merge/syncable.d.ts
51
+ - app/assets/builds/action_merge/syncable.d.ts.map
52
+ - app/assets/config/action_merge_manifest.js
53
+ - app/assets/stylesheets/action_merge/application.css
54
+ - app/channels/action_merge/sync_channel.rb
55
+ - app/channels/application_cable/channel.rb
56
+ - app/channels/application_cable/connection.rb
57
+ - app/controllers/action_merge/application_controller.rb
58
+ - app/helpers/action_merge/application_helper.rb
59
+ - app/javascript/action_merge/application.ts
60
+ - app/javascript/action_merge/network-adapter.ts
61
+ - app/javascript/action_merge/syncable.ts
62
+ - app/jobs/action_merge/application_job.rb
63
+ - app/mailers/action_merge/application_mailer.rb
64
+ - app/models/action_merge/application_record.rb
65
+ - app/models/action_merge/document.rb
66
+ - app/models/action_merge/document/chunk.rb
67
+ - app/models/action_merge/peer.rb
68
+ - app/models/action_merge/storage_adapter.rb
69
+ - app/views/layouts/action_merge/application.html.erb
70
+ - config/routes.rb
71
+ - db/migrate/20240910160133_create_action_merge_documents.rb
72
+ - lib/action_merge.rb
73
+ - lib/action_merge/assets.rb
74
+ - lib/action_merge/engine.rb
75
+ - lib/action_merge/helpers.rb
76
+ - lib/action_merge/syncable.rb
77
+ - lib/tasks/action_merge_tasks.rake
78
+ homepage: https://codeberg.org/reesericci/action_merge
79
+ licenses:
80
+ - LGPL-3.0-or-later
81
+ metadata:
82
+ homepage_uri: https://codeberg.org/reesericci/action_merge
83
+ source_code_uri: https://codeberg.org/reesericci/action_merge
84
+ changelog_uri: https://codeberg.org/reesericci/action_merge/releases
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.5.9
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Build local-first and collaborative Rails applications
104
+ test_files: []