action_merge 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []