Module: InstantRecord
- Defined in:
- lib/instant_record.rb,
lib/instant_record.rb,
lib/instant_record/client.rb,
lib/instant_record/engine.rb,
lib/instant_record/version.rb,
lib/instant_record/syncable.rb,
lib/instant_record/sync_window.rb,
lib/instant_record/local_schema.rb,
app/models/instant_record/change.rb,
lib/instant_record/configuration.rb,
lib/instant_record/sync_metadata.rb,
lib/instant_record/runtime_scoped.rb,
lib/instant_record/client/notifier.rb,
lib/instant_record/outbox_mutation.rb,
lib/instant_record/client/transport.rb,
app/models/instant_record/applied_mutation.rb,
app/models/instant_record/mutation_applier.rb,
app/controllers/instant_record/events_controller.rb,
app/controllers/instant_record/records_controller.rb,
app/controllers/instant_record/mutations_controller.rb,
app/controllers/instant_record/bootstraps_controller.rb,
lib/generators/instant_record/install/install_generator.rb
Defined Under Namespace
Modules: Client, Generators, LocalSchema, RuntimeScoped, Syncable Classes: AppliedMutation, BootstrapsController, Change, Configuration, Engine, EventsController, MutationApplier, MutationsController, OutboxMutation, RecordsController, SyncMetadata, SyncWindow
Constant Summary collapse
- DEFAULT_MOUNT_PATH =
"/instant_record".freeze
- CORS_HEADERS =
Both engine controllers serve a PWA that may run on another origin in development (Vite dev server); auth on the sync endpoints is out of scope for the PoC.
{ "access-control-allow-origin" => "*", "access-control-allow-methods" => "GET, POST, OPTIONS", "access-control-allow-headers" => "content-type, last-event-id" }.freeze
- TIME_PRECISION =
Every timestamp crossing the wire carries microseconds, in both directions. This used to differ per path — the change log and the outbox serialize through ActiveSupport's JSON encoder, which stops at milliseconds, while the bootstrap and records endpoints kept all six places — and the mismatch was not cosmetic. It loses (created_at, id) keyset neighbours between history pages, and it biases every last-write-wins comparison against whichever side was rounded, which silently discarded writes on both.
6- NETWORK_MARKER =
Query marker that tells the service worker to pass one request to the network instead of the local wasm runtime (see InstantRecord.server_url).
"instant_record_network=1".freeze
- COALESCE_PASSES =
How many times one tick will loop to absorb requests that arrived while it was working. A cap keeps a continuous writer from holding the VM forever — whatever is left is reported as pending and retried.
5- VERSION =
"0.1.0"
Class Method Summary collapse
-
.apply_events(events) ⇒ Object
Apply a batch of streamed events.
- .applying_client_mutation ⇒ Object
-
.applying_client_mutation? ⇒ Boolean
Server-side change logging (Syncable) must not fire while a client mutation is being applied — MutationApplier logs those itself.
-
.browser? ⇒ Boolean
True when running inside the browser (ruby.wasm).
- .config ⇒ Object
- .configure {|config| ... } ⇒ Object
-
.consume_stream_b64(chunk_b64) ⇒ Object
One chunk of the change stream, straight off the socket the worker holds.
- .cursor ⇒ Object
-
.endpoint ⇒ Object
Where the sync engine lives, so the holder of the stream knows what to open.
-
.fetch_history(model, before:, partition: nil, limit: nil) ⇒ Object
On-demand history page for a windowed model (see Client.fetch_history).
-
.fetch_history_b64(request_b64) ⇒ Object
Base64 entrypoint the service worker calls: the request never enters Ruby source as raw text (which would be a #{}-interpolation injection sink), only as a base64 token decoded here.
-
.fetch_history_json(request_json) ⇒ Object
String-in/string-out fetch_history for the service worker's evalAsync interop (no JS object bridging).
- .pending_count ⇒ Object
-
.prepare_database! ⇒ Object
Bring this runtime's database up to the app's schema.
-
.record_payload(record) ⇒ Object
The wire shape for one record, shared by the bootstrap and records endpoints and mirroring Change#as_event: clients apply all three through the same upsert path.
-
.server_url(path) ⇒ Object
One of your own app paths on the authoritative server, reachable from either runtime — for the rare action that must not be served locally (anything whose effect has to reach other clients through the server's change log).
-
.start(cold_boot: true) ⇒ Object
Begin background sync (browser runtime only; a no-op on the server).
- .started? ⇒ Boolean
-
.stream_closed_json ⇒ Object
The stream dropped: the poll is the delivery path again until it is back.
-
.stream_opened_json ⇒ Object
A stream just opened: forget any half-received frame from the last one and stand the catch-up poll down.
-
.streaming=(value) ⇒ Object
Tell the sync loop a stream is live, so its own catch-up poll stands down.
-
.sync(*models) ⇒ Object
Optional explicit allowlist of syncable models.
-
.sync_now ⇒ Object
Manual one-shot sync, usable before/without
start. -
.syncable_models ⇒ Object
Every model participating in sync: the explicit allowlist when set, otherwise all loaded Syncable includers.
- .synced_model(record_type) ⇒ Object
- .synced_models ⇒ Object
-
.tick ⇒ Object
One sync pass: drain the outbox up, changes down, notify tabs.
-
.tick_json ⇒ Object
A tick for the service worker, which needs to know whether to come back.
-
.wire_attributes(attributes) ⇒ Object
One record's attributes as they cross the wire, in either direction and on every path: the bootstrap and records endpoints, the change log, the outbox, and the server's rejection/superseded replies.
Class Method Details
.apply_events(events) ⇒ Object
Apply a batch of streamed events. Shares the tick's single-flight guard: applying while a sync pass runs would re-enter the VM, so the caller gets :busy and retries — the same contract as fetch_history.
236 237 238 |
# File 'lib/instant_record.rb', line 236 def apply_events(events) single_flight { Client.apply_events(events) } end |
.applying_client_mutation ⇒ Object
307 308 309 310 311 312 313 |
# File 'lib/instant_record.rb', line 307 def previous = ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] = true yield ensure ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] = previous end |
.applying_client_mutation? ⇒ Boolean
Server-side change logging (Syncable) must not fire while a client mutation is being applied — MutationApplier logs those itself. IsolatedExecutionState is fiber-aware for Falcon's fiber-per-request.
303 304 305 |
# File 'lib/instant_record.rb', line 303 def !!ActiveSupport::IsolatedExecutionState[:instant_record_applying_client_mutation] end |
.browser? ⇒ Boolean
True when running inside the browser (ruby.wasm). Delegates to wasmify-rails' Kernel#on_wasm?; kept as a method so it stays the single stub point for tests and the public name for apps.
42 43 44 |
# File 'lib/instant_record.rb', line 42 def browser? on_wasm? end |
.config ⇒ Object
46 47 48 |
# File 'lib/instant_record.rb', line 46 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
50 51 52 |
# File 'lib/instant_record.rb', line 50 def configure yield config end |
.consume_stream_b64(chunk_b64) ⇒ Object
One chunk of the change stream, straight off the socket the worker holds. Base64 for the same injection reason as fetch_history_b64. A :busy reply leaves the chunk unparsed, so resending it is safe and loses nothing.
243 244 245 246 247 248 249 250 251 |
# File 'lib/instant_record.rb', line 243 def consume_stream_b64(chunk_b64) chunk = Base64.strict_decode64(chunk_b64.to_s) result = single_flight { Client.consume_stream(chunk) } return JSON.generate(ok: false, busy: true) if result == :busy JSON.generate(ok: true, applied: result, cursor: cursor) rescue ArgumentError, TypeError => e JSON.generate(ok: false, error: e.) end |
.endpoint ⇒ Object
Where the sync engine lives, so the holder of the stream knows what to open. Reading it from here keeps one source of truth.
209 |
# File 'lib/instant_record.rb', line 209 def endpoint = config.endpoint |
.fetch_history(model, before:, partition: nil, limit: nil) ⇒ Object
On-demand history page for a windowed model (see Client.fetch_history). Shares the tick's single-flight guard both ways: a fetch during a sync pass returns :busy for the caller to retry, and a tick arriving while a fetch is mid-flight is skipped — asyncify re-entrancy is the browser runtime's crash class, so the VM never runs both at once.
193 194 195 196 197 |
# File 'lib/instant_record.rb', line 193 def fetch_history(model, before:, partition: nil, limit: nil) single_flight do Client.fetch_history(model, before: before, partition: partition, limit: limit) end end |
.fetch_history_b64(request_b64) ⇒ Object
Base64 entrypoint the service worker calls: the request never enters Ruby source as raw text (which would be a #{}-interpolation injection sink), only as a base64 token decoded here. Delegates to fetch_history_json for the actual work.
273 274 275 276 277 |
# File 'lib/instant_record.rb', line 273 def fetch_history_b64(request_b64) fetch_history_json(Base64.strict_decode64(request_b64.to_s)) rescue ArgumentError => e JSON.generate(ok: false, error: e.) end |
.fetch_history_json(request_json) ⇒ Object
String-in/string-out fetch_history for the service worker's evalAsync interop (no JS object bridging). Never raises across the boundary: errors come back as false, error:, a held single-flight guard as false, busy: true for the worker's retry loop.
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
# File 'lib/instant_record.rb', line 283 def fetch_history_json(request_json) request = JSON.parse(request_json) model = synced_model(request["type"]) return JSON.generate(ok: false, error: "unknown record type #{request["type"].inspect}") unless model before = request["before"] || {} result = fetch_history(model, partition: request["partition"], before: { created_at: before["created_at"], id: before["id"] }, limit: request["limit"]) return JSON.generate(ok: false, busy: true, error: "sync in flight") if result == :busy JSON.generate(result) rescue JSON::ParserError, ArgumentError, TypeError => e JSON.generate(ok: false, error: e.) end |
.pending_count ⇒ Object
185 |
# File 'lib/instant_record.rb', line 185 def pending_count = Client.pending_count |
.prepare_database! ⇒ Object
Bring this runtime's database up to the app's schema. Stands in for a bare
ActiveRecord::Tasks::DatabaseTasks.prepare_all at boot, which cannot see
the app's migrations from the browser VM's working directory and so leaves
a returning visitor on the schema they first booted with — see LocalSchema.
112 |
# File 'lib/instant_record.rb', line 112 def prepare_database! = LocalSchema.prepare! |
.record_payload(record) ⇒ Object
The wire shape for one record, shared by the bootstrap and records endpoints and mirroring Change#as_event: clients apply all three through the same upsert path.
99 100 101 102 103 104 105 106 |
# File 'lib/instant_record.rb', line 99 def record_payload(record) { type: record.class.name, id: record.id, version: record[:server_version], attributes: wire_attributes(record.attributes) } end |
.server_url(path) ⇒ Object
One of your own app paths on the authoritative server, reachable from either runtime — for the rare action that must not be served locally (anything whose effect has to reach other clients through the server's change log).
The sync endpoint already points at that server, so an app path hangs off the same base: absolute when the PWA is served cross-origin (a Vite dev server), empty when the endpoint is the same-origin default. The marker tells the service worker to pass this one request to the network instead of the local runtime; on the server there is no service worker and it is ignored — which is why app code builds this URL without asking which runtime it is in.
223 224 225 |
# File 'lib/instant_record.rb', line 223 def server_url(path) "#{endpoint.to_s.delete_suffix(mount_path)}#{path}?#{NETWORK_MARKER}" end |
.start(cold_boot: true) ⇒ Object
Begin background sync (browser runtime only; a no-op on the server).
The gem's service worker shim schedules tick on config.sync_interval.
cold_boot: false skips the boot-time window eviction — the service
worker passes it when it was restarted under already-open tabs (an idle
SW restart mid-read must not trim scrollback the reader is holding).
119 120 121 122 123 124 |
# File 'lib/instant_record.rb', line 119 def start(cold_boot: true) return false unless browser? Client.request_eviction if cold_boot @started = true end |
.started? ⇒ Boolean
126 |
# File 'lib/instant_record.rb', line 126 def started? = !!@started |
.stream_closed_json ⇒ Object
The stream dropped: the poll is the delivery path again until it is back.
264 265 266 267 |
# File 'lib/instant_record.rb', line 264 def stream_closed_json result = single_flight { self.streaming = false } JSON.generate(result == :busy ? { ok: false, busy: true } : { ok: true }) end |
.stream_opened_json ⇒ Object
A stream just opened: forget any half-received frame from the last one and stand the catch-up poll down.
255 256 257 258 259 260 261 |
# File 'lib/instant_record.rb', line 255 def stream_opened_json result = single_flight do Client.reset_stream self.streaming = true end JSON.generate(result == :busy ? { ok: false, busy: true } : { ok: true }) end |
.streaming=(value) ⇒ Object
Tell the sync loop a stream is live, so its own catch-up poll stands down. Set false when the stream drops and the poll should take over again.
229 230 231 |
# File 'lib/instant_record.rb', line 229 def streaming=(value) Client.streaming = value end |
.sync(*models) ⇒ Object
Optional explicit allowlist of syncable models. Without it, any model that includes InstantRecord::Syncable is syncable.
56 57 58 |
# File 'lib/instant_record.rb', line 56 def sync(*models) @synced_models = models.flatten end |
.sync_now ⇒ Object
Manual one-shot sync, usable before/without start.
165 166 167 168 |
# File 'lib/instant_record.rb', line 165 def sync_now @started = true tick end |
.syncable_models ⇒ Object
Every model participating in sync: the explicit allowlist when set, otherwise all loaded Syncable includers.
73 74 75 76 77 78 79 80 |
# File 'lib/instant_record.rb', line 73 def syncable_models return synced_models if synced_models.any? ActiveRecord::Base.descendants.select do |model| !model.abstract_class? && model.name && model.respond_to?(:instant_record_syncable?) && model.instant_record_syncable? end end |
.synced_model(record_type) ⇒ Object
64 65 66 67 68 69 |
# File 'lib/instant_record.rb', line 64 def synced_model(record_type) return synced_models.find { |model| model.name == record_type } if synced_models.any? klass = record_type.to_s.safe_constantize klass if klass.respond_to?(:instant_record_syncable?) && klass.instant_record_syncable? end |
.synced_models ⇒ Object
60 61 62 |
# File 'lib/instant_record.rb', line 60 def synced_models @synced_models ||= [] end |
.tick ⇒ Object
One sync pass: drain the outbox up, changes down, notify tabs.
Requests that arrive mid-pass are coalesced rather than queued or dropped. They cannot start a second pass — the VM must never be re-entered — so the running pass notes them and goes round again. A burst of writes therefore costs a couple of batched requests instead of one per write, and no write is left sitting in the outbox waiting for a later trigger.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/instant_record.rb', line 140 def tick return :not_started unless started? if @ticking @tick_again = true return :busy end single_flight do COALESCE_PASSES.times do @tick_again = false outbox_before = Client.pending_count Client.sync_pass # Go round again only for a request that arrived mid-pass AND an outbox # that actually moved. Repeating a pass that changed nothing would just # hammer a server already refusing the same batch; the caller's backoff # is where that belongs. break unless @tick_again && Client.pending_count != outbox_before end :ok end end |
.tick_json ⇒ Object
A tick for the service worker, which needs to know whether to come back.
There is no heartbeat: writes trigger a pass, the change stream carries
everything inbound, and this pending count is the only reason to retry —
so when it reaches zero an idle client goes quiet entirely.
174 175 176 177 178 179 180 181 182 183 |
# File 'lib/instant_record.rb', line 174 def tick_json result = tick return JSON.generate(ok: false, busy: true) if result == :busy return JSON.generate(ok: false, started: false) if result == :not_started JSON.generate(ok: true, pending: pending_count) rescue StandardError => e # Never raise across the JS boundary; a failed pass is a retry, not a crash. JSON.generate(ok: false, error: e., pending: safe_pending_count) end |
.wire_attributes(attributes) ⇒ Object
One record's attributes as they cross the wire, in either direction and on every path: the bootstrap and records endpoints, the change log, the outbox, and the server's rejection/superseded replies. sync_state is client-local and never serialized. Timestamps go out at TIME_PRECISION so the (created_at, id) keyset cursor round-trips exactly and both sides of a last-write-wins comparison are measured with the same ruler.
88 89 90 91 92 93 94 |
# File 'lib/instant_record.rb', line 88 def wire_attributes(attributes) attributes.except("sync_state").transform_values do |value| # usec, not iso8601: a Date answers to iso8601 but takes no precision # argument, and its default JSON form already round-trips exactly. value.respond_to?(:usec) ? value.iso8601(TIME_PRECISION) : value end end |