instant_record 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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +378 -0
  5. data/app/controllers/instant_record/bootstraps_controller.rb +34 -0
  6. data/app/controllers/instant_record/events_controller.rb +59 -0
  7. data/app/controllers/instant_record/mutations_controller.rb +21 -0
  8. data/app/controllers/instant_record/records_controller.rb +46 -0
  9. data/app/models/instant_record/applied_mutation.rb +9 -0
  10. data/app/models/instant_record/change.rb +30 -0
  11. data/app/models/instant_record/mutation_applier.rb +133 -0
  12. data/config/routes.rb +7 -0
  13. data/db/migrate/20260721000002_create_instant_record_outbox.rb +12 -0
  14. data/db/migrate/20260721000003_create_instant_record_sync_metadata.rb +9 -0
  15. data/db/migrate/20260721000101_create_instant_record_changes.rb +13 -0
  16. data/db/migrate/20260721000102_create_instant_record_applied_mutations.rb +10 -0
  17. data/lib/generators/instant_record/install/install_generator.rb +53 -0
  18. data/lib/generators/instant_record/install/templates/database.js +32 -0
  19. data/lib/generators/instant_record/install/templates/index.html +69 -0
  20. data/lib/generators/instant_record/install/templates/rails.sw.js +750 -0
  21. data/lib/instant_record/client/notifier.rb +23 -0
  22. data/lib/instant_record/client/transport.rb +90 -0
  23. data/lib/instant_record/client.rb +465 -0
  24. data/lib/instant_record/configuration.rb +19 -0
  25. data/lib/instant_record/engine.rb +52 -0
  26. data/lib/instant_record/local_schema.rb +75 -0
  27. data/lib/instant_record/outbox_mutation.rb +24 -0
  28. data/lib/instant_record/pglite_compat.rb +40 -0
  29. data/lib/instant_record/runtime_scoped.rb +21 -0
  30. data/lib/instant_record/sync_metadata.rb +16 -0
  31. data/lib/instant_record/sync_window.rb +82 -0
  32. data/lib/instant_record/syncable.rb +106 -0
  33. data/lib/instant_record/version.rb +3 -0
  34. data/lib/instant_record.rb +356 -0
  35. data/lib/tasks/instant_record.rake +53 -0
  36. metadata +108 -0
@@ -0,0 +1,356 @@
1
+ require "json"
2
+ require "base64"
3
+
4
+ module InstantRecord
5
+ DEFAULT_MOUNT_PATH = "/instant_record".freeze
6
+
7
+ # Both engine controllers serve a PWA that may run on another origin in
8
+ # development (Vite dev server); auth on the sync endpoints is out of scope
9
+ # for the PoC.
10
+ CORS_HEADERS = {
11
+ "access-control-allow-origin" => "*",
12
+ "access-control-allow-methods" => "GET, POST, OPTIONS",
13
+ "access-control-allow-headers" => "content-type, last-event-id"
14
+ }.freeze
15
+
16
+ # Every timestamp crossing the wire carries microseconds, in both directions.
17
+ # This used to differ per path — the change log and the outbox serialize
18
+ # through ActiveSupport's JSON encoder, which stops at milliseconds, while the
19
+ # bootstrap and records endpoints kept all six places — and the mismatch was
20
+ # not cosmetic. It loses (created_at, id) keyset neighbours between history
21
+ # pages, and it biases every last-write-wins comparison against whichever side
22
+ # was rounded, which silently discarded writes on both.
23
+ TIME_PRECISION = 6
24
+
25
+ # Query marker that tells the service worker to pass one request to the
26
+ # network instead of the local wasm runtime (see InstantRecord.server_url).
27
+ NETWORK_MARKER = "instant_record_network=1".freeze
28
+ end
29
+
30
+ require "active_support/isolated_execution_state"
31
+ require "instant_record/version"
32
+ require "instant_record/configuration"
33
+ require "instant_record/runtime_scoped"
34
+ require "wasmify-rails" # browser build tasks + PGlite adapter; inert outside wasm builds
35
+ require "instant_record/engine" if defined?(Rails::Engine)
36
+
37
+ module InstantRecord
38
+ class << self
39
+ # True when running inside the browser (ruby.wasm). Delegates to
40
+ # wasmify-rails' Kernel#on_wasm?; kept as a method so it stays the
41
+ # single stub point for tests and the public name for apps.
42
+ def browser?
43
+ on_wasm?
44
+ end
45
+
46
+ def config
47
+ @config ||= Configuration.new
48
+ end
49
+
50
+ def configure
51
+ yield config
52
+ end
53
+
54
+ # Optional explicit allowlist of syncable models. Without it, any model
55
+ # that includes InstantRecord::Syncable is syncable.
56
+ def sync(*models)
57
+ @synced_models = models.flatten
58
+ end
59
+
60
+ def synced_models
61
+ @synced_models ||= []
62
+ end
63
+
64
+ def synced_model(record_type)
65
+ return synced_models.find { |model| model.name == record_type } if synced_models.any?
66
+
67
+ klass = record_type.to_s.safe_constantize
68
+ klass if klass.respond_to?(:instant_record_syncable?) && klass.instant_record_syncable?
69
+ end
70
+
71
+ # Every model participating in sync: the explicit allowlist when set,
72
+ # otherwise all loaded Syncable includers.
73
+ def syncable_models
74
+ return synced_models if synced_models.any?
75
+
76
+ ActiveRecord::Base.descendants.select do |model|
77
+ !model.abstract_class? && model.name &&
78
+ model.respond_to?(:instant_record_syncable?) && model.instant_record_syncable?
79
+ end
80
+ end
81
+
82
+ # One record's attributes as they cross the wire, in either direction and on
83
+ # every path: the bootstrap and records endpoints, the change log, the
84
+ # outbox, and the server's rejection/superseded replies. sync_state is
85
+ # client-local and never serialized. Timestamps go out at TIME_PRECISION so
86
+ # the (created_at, id) keyset cursor round-trips exactly and both sides of a
87
+ # last-write-wins comparison are measured with the same ruler.
88
+ def wire_attributes(attributes)
89
+ attributes.except("sync_state").transform_values do |value|
90
+ # usec, not iso8601: a Date answers to iso8601 but takes no precision
91
+ # argument, and its default JSON form already round-trips exactly.
92
+ value.respond_to?(:usec) ? value.iso8601(TIME_PRECISION) : value
93
+ end
94
+ end
95
+
96
+ # The wire shape for one record, shared by the bootstrap and records
97
+ # endpoints and mirroring Change#as_event: clients apply all three through
98
+ # the same upsert path.
99
+ def record_payload(record)
100
+ {
101
+ type: record.class.name,
102
+ id: record.id,
103
+ version: record[:server_version],
104
+ attributes: wire_attributes(record.attributes)
105
+ }
106
+ end
107
+
108
+ # Bring this runtime's database up to the app's schema. Stands in for a bare
109
+ # `ActiveRecord::Tasks::DatabaseTasks.prepare_all` at boot, which cannot see
110
+ # the app's migrations from the browser VM's working directory and so leaves
111
+ # a returning visitor on the schema they first booted with — see LocalSchema.
112
+ def prepare_database! = LocalSchema.prepare!
113
+
114
+ # Begin background sync (browser runtime only; a no-op on the server).
115
+ # The gem's service worker shim schedules `tick` on config.sync_interval.
116
+ # cold_boot: false skips the boot-time window eviction — the service
117
+ # worker passes it when it was restarted under already-open tabs (an idle
118
+ # SW restart mid-read must not trim scrollback the reader is holding).
119
+ def start(cold_boot: true)
120
+ return false unless browser?
121
+
122
+ Client.request_eviction if cold_boot
123
+ @started = true
124
+ end
125
+
126
+ def started? = !!@started
127
+
128
+ # How many times one tick will loop to absorb requests that arrived while it
129
+ # was working. A cap keeps a continuous writer from holding the VM forever —
130
+ # whatever is left is reported as pending and retried.
131
+ COALESCE_PASSES = 5
132
+
133
+ # One sync pass: drain the outbox up, changes down, notify tabs.
134
+ #
135
+ # Requests that arrive mid-pass are coalesced rather than queued or dropped.
136
+ # They cannot start a second pass — the VM must never be re-entered — so the
137
+ # running pass notes them and goes round again. A burst of writes therefore
138
+ # costs a couple of batched requests instead of one per write, and no write
139
+ # is left sitting in the outbox waiting for a later trigger.
140
+ def tick
141
+ return :not_started unless started?
142
+
143
+ if @ticking
144
+ @tick_again = true
145
+ return :busy
146
+ end
147
+
148
+ single_flight do
149
+ COALESCE_PASSES.times do
150
+ @tick_again = false
151
+ outbox_before = Client.pending_count
152
+ Client.sync_pass
153
+
154
+ # Go round again only for a request that arrived mid-pass AND an outbox
155
+ # that actually moved. Repeating a pass that changed nothing would just
156
+ # hammer a server already refusing the same batch; the caller's backoff
157
+ # is where that belongs.
158
+ break unless @tick_again && Client.pending_count != outbox_before
159
+ end
160
+ :ok
161
+ end
162
+ end
163
+
164
+ # Manual one-shot sync, usable before/without `start`.
165
+ def sync_now
166
+ @started = true
167
+ tick
168
+ end
169
+
170
+ # A tick for the service worker, which needs to know whether to come back.
171
+ # There is no heartbeat: writes trigger a pass, the change stream carries
172
+ # everything inbound, and this `pending` count is the only reason to retry —
173
+ # so when it reaches zero an idle client goes quiet entirely.
174
+ def tick_json
175
+ result = tick
176
+ return JSON.generate(ok: false, busy: true) if result == :busy
177
+ return JSON.generate(ok: false, started: false) if result == :not_started
178
+
179
+ JSON.generate(ok: true, pending: pending_count)
180
+ rescue StandardError => e
181
+ # Never raise across the JS boundary; a failed pass is a retry, not a crash.
182
+ JSON.generate(ok: false, error: e.message, pending: safe_pending_count)
183
+ end
184
+
185
+ def pending_count = Client.pending_count
186
+ def cursor = Client.cursor
187
+
188
+ # On-demand history page for a windowed model (see Client.fetch_history).
189
+ # Shares the tick's single-flight guard both ways: a fetch during a sync
190
+ # pass returns :busy for the caller to retry, and a tick arriving while a
191
+ # fetch is mid-flight is skipped — asyncify re-entrancy is the browser
192
+ # runtime's crash class, so the VM never runs both at once.
193
+ def fetch_history(model, before:, partition: nil, limit: nil)
194
+ single_flight do
195
+ Client.fetch_history(model, before: before, partition: partition, limit: limit)
196
+ end
197
+ end
198
+
199
+ # --- Streamed changes -----------------------------------------------------
200
+ # Ruby can't hold the change stream itself: every chunk it awaited would
201
+ # suspend the single-flight tick, so a tailing window would starve outbox
202
+ # drains for its whole duration. That is why the sync loop polls with
203
+ # window=0 by default. Something outside the VM — the service worker — can
204
+ # hold the stream instead and hand events in here, which is what turns
205
+ # tick-bounded delivery into push.
206
+
207
+ # Where the sync engine lives, so the holder of the stream knows what to
208
+ # open. Reading it from here keeps one source of truth.
209
+ def endpoint = config.endpoint
210
+
211
+ # One of your own app paths on the authoritative server, reachable from
212
+ # either runtime — for the rare action that must not be served locally
213
+ # (anything whose effect has to reach other clients through the server's
214
+ # change log).
215
+ #
216
+ # The sync endpoint already points at that server, so an app path hangs off
217
+ # the same base: absolute when the PWA is served cross-origin (a Vite dev
218
+ # server), empty when the endpoint is the same-origin default. The marker
219
+ # tells the service worker to pass this one request to the network instead of
220
+ # the local runtime; on the server there is no service worker and it is
221
+ # ignored — which is why app code builds this URL without asking which
222
+ # runtime it is in.
223
+ def server_url(path)
224
+ "#{endpoint.to_s.delete_suffix(mount_path)}#{path}?#{NETWORK_MARKER}"
225
+ end
226
+
227
+ # Tell the sync loop a stream is live, so its own catch-up poll stands down.
228
+ # Set false when the stream drops and the poll should take over again.
229
+ def streaming=(value)
230
+ Client.streaming = value
231
+ end
232
+
233
+ # Apply a batch of streamed events. Shares the tick's single-flight guard:
234
+ # applying while a sync pass runs would re-enter the VM, so the caller gets
235
+ # :busy and retries — the same contract as fetch_history.
236
+ def apply_events(events)
237
+ single_flight { Client.apply_events(events) }
238
+ end
239
+
240
+ # One chunk of the change stream, straight off the socket the worker holds.
241
+ # Base64 for the same injection reason as fetch_history_b64. A :busy reply
242
+ # leaves the chunk unparsed, so resending it is safe and loses nothing.
243
+ def consume_stream_b64(chunk_b64)
244
+ chunk = Base64.strict_decode64(chunk_b64.to_s)
245
+ result = single_flight { Client.consume_stream(chunk) }
246
+ return JSON.generate(ok: false, busy: true) if result == :busy
247
+
248
+ JSON.generate(ok: true, applied: result, cursor: cursor)
249
+ rescue ArgumentError, TypeError => e
250
+ JSON.generate(ok: false, error: e.message)
251
+ end
252
+
253
+ # A stream just opened: forget any half-received frame from the last one and
254
+ # stand the catch-up poll down.
255
+ def stream_opened_json
256
+ result = single_flight do
257
+ Client.reset_stream
258
+ self.streaming = true
259
+ end
260
+ JSON.generate(result == :busy ? { ok: false, busy: true } : { ok: true })
261
+ end
262
+
263
+ # The stream dropped: the poll is the delivery path again until it is back.
264
+ def stream_closed_json
265
+ result = single_flight { self.streaming = false }
266
+ JSON.generate(result == :busy ? { ok: false, busy: true } : { ok: true })
267
+ end
268
+
269
+ # Base64 entrypoint the service worker calls: the request never enters
270
+ # Ruby source as raw text (which would be a #{}-interpolation injection
271
+ # sink), only as a base64 token decoded here. Delegates to
272
+ # fetch_history_json for the actual work.
273
+ def fetch_history_b64(request_b64)
274
+ fetch_history_json(Base64.strict_decode64(request_b64.to_s))
275
+ rescue ArgumentError => e
276
+ JSON.generate(ok: false, error: e.message)
277
+ end
278
+
279
+ # String-in/string-out fetch_history for the service worker's evalAsync
280
+ # interop (no JS object bridging). Never raises across the boundary:
281
+ # errors come back as {ok: false, error:}, a held single-flight guard as
282
+ # {ok: false, busy: true} for the worker's retry loop.
283
+ def fetch_history_json(request_json)
284
+ request = JSON.parse(request_json)
285
+ model = synced_model(request["type"])
286
+ return JSON.generate(ok: false, error: "unknown record type #{request["type"].inspect}") unless model
287
+
288
+ before = request["before"] || {}
289
+ result = fetch_history(model,
290
+ partition: request["partition"],
291
+ before: { created_at: before["created_at"], id: before["id"] },
292
+ limit: request["limit"])
293
+ return JSON.generate(ok: false, busy: true, error: "sync in flight") if result == :busy
294
+
295
+ JSON.generate(result)
296
+ rescue JSON::ParserError, ArgumentError, TypeError => e
297
+ JSON.generate(ok: false, error: e.message)
298
+ end
299
+
300
+ # Server-side change logging (Syncable) must not fire while a client
301
+ # mutation is being applied — MutationApplier logs those itself.
302
+ # IsolatedExecutionState is fiber-aware for Falcon's fiber-per-request.
303
+ def applying_client_mutation?
304
+ !!ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation]
305
+ end
306
+
307
+ def applying_client_mutation
308
+ previous = ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation]
309
+ ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] = true
310
+ yield
311
+ ensure
312
+ ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] = previous
313
+ end
314
+
315
+ private
316
+
317
+ # Where the engine is mounted on the authoritative server. The endpoint IS
318
+ # that mount, so stripping it leaves the app's own base — read from config
319
+ # rather than assumed, because an app is free to move the mount.
320
+ def mount_path
321
+ configured = Rails.application.config.instant_record.mount_path if defined?(Rails) && Rails.application
322
+ configured.is_a?(String) ? configured : DEFAULT_MOUNT_PATH
323
+ end
324
+
325
+ # The retry decision must survive a database that is itself unhappy:
326
+ # reporting "nothing pending" there would strand queued writes forever.
327
+ def safe_pending_count
328
+ pending_count
329
+ rescue StandardError
330
+ 1
331
+ end
332
+
333
+ # Ticks and history fetches share one guard: the wasm VM must never run
334
+ # two sync entry points concurrently (asyncify re-entrancy), so whichever
335
+ # is in flight makes the other answer :busy.
336
+ def single_flight
337
+ return :busy if @ticking
338
+
339
+ @ticking = true
340
+ begin
341
+ yield
342
+ ensure
343
+ @ticking = false
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ ActiveSupport.on_load(:active_record) do
350
+ require "instant_record/local_schema"
351
+ require "instant_record/sync_window"
352
+ require "instant_record/syncable"
353
+ require "instant_record/outbox_mutation"
354
+ require "instant_record/sync_metadata"
355
+ require "instant_record/client"
356
+ end
@@ -0,0 +1,53 @@
1
+ namespace :instant_record do
2
+ desc "Build the browser bundle: compiles the Ruby wasm core (once) and packs app.wasm"
3
+ task :build do
4
+ require "wasmify-rails"
5
+
6
+ # wasmify:pack precompiles assets in a subprocess; this flag stops the
7
+ # assets:precompile enhancement below from recursing back into this task.
8
+ ENV["INSTANT_RECORD_SKIP_BUILD"] = "1"
9
+
10
+ core = File.join(Wasmify::Rails.config.tmp_dir, "ruby-core.wasm")
11
+ Rake::Task["wasmify:build:core"].invoke unless File.exist?(core)
12
+ Rake::Task["wasmify:pack"].invoke
13
+ Rake::Task["instant_record:stamp_sw"].invoke
14
+ end
15
+
16
+ # Stamp the app.wasm digest into the service worker so its bytes change on
17
+ # every rebuild. The browser's update check then installs the new worker,
18
+ # which claims open tabs and reloads them onto the new bundle — without
19
+ # this, installed clients keep running the old VM indefinitely.
20
+ desc "Stamp the current app.wasm digest into pwa/rails.sw.js"
21
+ task :stamp_sw do
22
+ require "digest"
23
+
24
+ root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Pathname.new(Dir.pwd)
25
+ sw = root.join("pwa/rails.sw.js")
26
+ wasm = root.join("pwa/public/app.wasm")
27
+ next unless File.exist?(sw) && File.exist?(wasm)
28
+
29
+ version = Digest::SHA256.file(wasm).hexdigest[0, 12]
30
+ contents = File.read(sw)
31
+ stamped = contents
32
+ .sub(/__INSTANT_RECORD_BUILD_VERSION__/, version)
33
+ .gsub(/const BUILD_VERSION = "[0-9a-f]{12}";/, %(const BUILD_VERSION = "#{version}";))
34
+ File.write(sw, stamped)
35
+ puts "Stamped service worker build version: #{version}"
36
+ end
37
+ end
38
+
39
+ # Opt-in deploy hook: build the browser bundle during assets:precompile.
40
+ #
41
+ # # config/application.rb
42
+ # config.instant_record.build_on_precompile = true
43
+ #
44
+ # Off by default: the build needs the wasm toolchain and network access for
45
+ # ruby.wasm artifacts, which not every deploy environment has.
46
+ if Rake::Task.task_defined?("assets:precompile")
47
+ Rake::Task["assets:precompile"].enhance do
48
+ next if ENV["INSTANT_RECORD_SKIP_BUILD"]
49
+ next unless Rails.application.config.instant_record.build_on_precompile
50
+
51
+ Rake::Task["instant_record:build"].invoke
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: instant_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kieran Klaassen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-07-24 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: wasmify-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.5'
40
+ description: InstantRecord boots real Active Record inside the browser with ruby.wasm
41
+ and wasmify-rails, persists to PGlite, and writes optimistically through a durable
42
+ outbox that syncs to a Rails + Postgres server.
43
+ email:
44
+ - kieranklaassen@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - app/controllers/instant_record/bootstraps_controller.rb
53
+ - app/controllers/instant_record/events_controller.rb
54
+ - app/controllers/instant_record/mutations_controller.rb
55
+ - app/controllers/instant_record/records_controller.rb
56
+ - app/models/instant_record/applied_mutation.rb
57
+ - app/models/instant_record/change.rb
58
+ - app/models/instant_record/mutation_applier.rb
59
+ - config/routes.rb
60
+ - db/migrate/20260721000002_create_instant_record_outbox.rb
61
+ - db/migrate/20260721000003_create_instant_record_sync_metadata.rb
62
+ - db/migrate/20260721000101_create_instant_record_changes.rb
63
+ - db/migrate/20260721000102_create_instant_record_applied_mutations.rb
64
+ - lib/generators/instant_record/install/install_generator.rb
65
+ - lib/generators/instant_record/install/templates/database.js
66
+ - lib/generators/instant_record/install/templates/index.html
67
+ - lib/generators/instant_record/install/templates/rails.sw.js
68
+ - lib/instant_record.rb
69
+ - lib/instant_record/client.rb
70
+ - lib/instant_record/client/notifier.rb
71
+ - lib/instant_record/client/transport.rb
72
+ - lib/instant_record/configuration.rb
73
+ - lib/instant_record/engine.rb
74
+ - lib/instant_record/local_schema.rb
75
+ - lib/instant_record/outbox_mutation.rb
76
+ - lib/instant_record/pglite_compat.rb
77
+ - lib/instant_record/runtime_scoped.rb
78
+ - lib/instant_record/sync_metadata.rb
79
+ - lib/instant_record/sync_window.rb
80
+ - lib/instant_record/syncable.rb
81
+ - lib/instant_record/version.rb
82
+ - lib/tasks/instant_record.rake
83
+ homepage: https://github.com/kieranklaassen/instant_record
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/kieranklaassen/instant_record
88
+ source_code_uri: https://github.com/kieranklaassen/instant_record
89
+ changelog_uri: https://github.com/kieranklaassen/instant_record/blob/main/CHANGELOG.md
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.3'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.2
105
+ specification_version: 4
106
+ summary: Rails models that run in the browser, sync to your server, and keep working
107
+ offline
108
+ test_files: []