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

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_mutationObject



307
308
309
310
311
312
313
# File 'lib/instant_record.rb', line 307

def applying_client_mutation
  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.

Returns:

  • (Boolean)


303
304
305
# File 'lib/instant_record.rb', line 303

def applying_client_mutation?
  !!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.

Returns:

  • (Boolean)


42
43
44
# File 'lib/instant_record.rb', line 42

def browser?
  on_wasm?
end

.configObject



46
47
48
# File 'lib/instant_record.rb', line 46

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields:



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.message)
end

.cursorObject



186
# File 'lib/instant_record.rb', line 186

def cursor = Client.cursor

.endpointObject

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.message)
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.message)
end

.pending_countObject



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

Returns:

  • (Boolean)


126
# File 'lib/instant_record.rb', line 126

def started? = !!@started

.stream_closed_jsonObject

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_jsonObject

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_nowObject

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_modelsObject

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_modelsObject



60
61
62
# File 'lib/instant_record.rb', line 60

def synced_models
  @synced_models ||= []
end

.tickObject

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_jsonObject

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.message, 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