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,23 @@
1
+ module InstantRecord
2
+ module Client
3
+ module Notifier
4
+ # Tells every open tab that local data changed so pages re-render.
5
+ # Runs in the service worker context; indexes the client list rather
6
+ # than passing Ruby procs to JS (narrower interop surface).
7
+ class JsClients
8
+ def initialize
9
+ require "js"
10
+ end
11
+
12
+ def records_changed
13
+ clients = JS.global[:clients].matchAll.await
14
+ message = JS.eval("return {type: 'records_changed'}")
15
+
16
+ clients[:length].to_i.times do |i|
17
+ clients[i].postMessage(message)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,90 @@
1
+ require "json"
2
+
3
+ module InstantRecord
4
+ module Client
5
+ module Transport
6
+ Error = Class.new(StandardError)
7
+
8
+ # Incremental parser for text/event-stream bodies. Pure Ruby so the
9
+ # sync loop is unit-testable without a browser. Yields one hash per
10
+ # event, with the SSE id merged in as "cursor".
11
+ class SseParser
12
+ def initialize(&on_event)
13
+ @buffer = +""
14
+ @on_event = on_event
15
+ end
16
+
17
+ def feed(chunk)
18
+ @buffer << chunk
19
+
20
+ while (separator = @buffer.index("\n\n"))
21
+ raw_event = @buffer.slice!(0, separator + 2)
22
+
23
+ id = raw_event[/^id: (.+)$/, 1]
24
+ data = raw_event[/^data: (.+)$/, 1]
25
+ next unless data
26
+
27
+ event = JSON.parse(data)
28
+ event["cursor"] = id.to_i if id
29
+ @on_event.call(event)
30
+ end
31
+ end
32
+ end
33
+
34
+ # Browser implementation over the JS fetch API via ruby.wasm's `js`
35
+ # gem. Every `.await` is an asyncify suspension point, so this class
36
+ # must only run under `evalAsync`.
37
+ class JsFetch
38
+ def initialize
39
+ require "js"
40
+ end
41
+
42
+ def get_json(path)
43
+ response = JS.global.fetch(url(path)).await
44
+ raise Error, "GET #{path} -> #{response[:status]}" unless response[:ok] == JS::True
45
+
46
+ JSON.parse(response.text.await.to_s)
47
+ end
48
+
49
+ def post_json(path, payload)
50
+ options = JS.eval("return {}")
51
+ options[:method] = "POST"
52
+ headers = JS.eval("return {}")
53
+ headers["Content-Type"] = "application/json"
54
+ options[:headers] = headers
55
+ options[:body] = JSON.generate(payload)
56
+
57
+ response = JS.global.fetch(url(path), options).await
58
+ raise Error, "POST #{path} -> #{response[:status]}" unless response[:ok] == JS::True
59
+
60
+ JSON.parse(response.text.await.to_s)
61
+ end
62
+
63
+ # Streams an SSE response, yielding one parsed event hash at a time.
64
+ # The server closes its bounded window; we return when the body ends.
65
+ def each_event(path, &block)
66
+ response = JS.global.fetch(url(path)).await
67
+ raise Error, "GET #{path} -> #{response[:status]}" unless response[:ok] == JS::True
68
+
69
+ parser = SseParser.new(&block)
70
+ reader = response[:body].getReader
71
+ decoder = JS.global[:TextDecoder].new
72
+ @stream_options ||= JS.eval("return {stream: true}")
73
+
74
+ loop do
75
+ result = reader.read.await
76
+ break if result[:done] == JS::True
77
+
78
+ parser.feed(decoder.decode(result[:value], @stream_options).to_s)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def url(path)
85
+ "#{InstantRecord.config.endpoint}#{path}"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,465 @@
1
+ require "uri"
2
+ require "time"
3
+ require "instant_record/client/transport"
4
+ require "instant_record/client/notifier"
5
+
6
+ module InstantRecord
7
+ # Browser-side sync client. Ruby owns the whole sync loop — transport
8
+ # included — via JS fetch interop; the service worker shim only schedules
9
+ # `InstantRecord.tick` (wasm Ruby cannot sleep without blocking the VM).
10
+ module Client
11
+ # Columns the gem keeps for its own bookkeeping. A remote value that differs
12
+ # in these alone is not a change to the row, it is our own write being
13
+ # acknowledged twice.
14
+ BOOKKEEPING_COLUMNS = %w[server_version sync_state].freeze
15
+
16
+ class << self
17
+ # Applying remote state must never enqueue new outbox mutations.
18
+ def applying_remote? = @applying_remote
19
+ def applying_remote(&block)
20
+ @applying_remote = true
21
+ block.call
22
+ ensure
23
+ @applying_remote = false
24
+ end
25
+
26
+ # Seams: real implementations are JS-interop-backed and browser-only;
27
+ # tests inject doubles so the loop runs under CRuby.
28
+ attr_writer :transport, :notifier
29
+
30
+ def transport
31
+ @transport ||= Transport::JsFetch.new
32
+ end
33
+
34
+ def notifier
35
+ @notifier ||= Notifier::JsClients.new
36
+ end
37
+
38
+ # One full pass: outbox up, changes down, tabs notified when anything
39
+ # moved. Called under InstantRecord.tick's single-flight guard. A client
40
+ # that has never synced hydrates from the bootstrap snapshot instead of
41
+ # replaying the whole change log from cursor 0.
42
+ def sync_pass
43
+ changed = evict_beyond_windows
44
+ changed = drain || changed
45
+ changed = (bootstrapped? ? poll_changes : bootstrap) || changed
46
+ notifier.records_changed if changed
47
+ changed
48
+ end
49
+
50
+ # Arm the boot-time trim; InstantRecord.start sets this on cold boots
51
+ # only. Runs once, on the next sync pass.
52
+ def request_eviction
53
+ @eviction_pending = true
54
+ end
55
+
56
+ # Trim every windowed model back to its declared window. delete_all
57
+ # under applying_remote: no callbacks, no outbox rows. Pending rows are
58
+ # excluded in the WHERE — an unsynced write is never evicted. Returns
59
+ # true when anything was deleted.
60
+ def evict_beyond_windows
61
+ return false unless @eviction_pending
62
+
63
+ @eviction_pending = false
64
+ evicted = false
65
+ InstantRecord.syncable_models.each do |model|
66
+ window = model.instant_record_sync_window
67
+ next unless window
68
+
69
+ applying_remote do
70
+ evicted = true if window.beyond_window(model.where.not(sync_state: "pending")).delete_all.positive?
71
+ end
72
+ end
73
+ evicted
74
+ end
75
+
76
+ def pending_count
77
+ OutboxMutation.count
78
+ end
79
+
80
+ def pending_mutations
81
+ OutboxMutation.ordered.map(&:as_mutation)
82
+ end
83
+
84
+ def cursor
85
+ SyncMetadata.get("cursor").to_i
86
+ end
87
+
88
+ def cursor=(value)
89
+ SyncMetadata.set("cursor", value)
90
+ end
91
+
92
+ # nil-aware on purpose: cursor 0 (bootstrapped against an empty change
93
+ # log) and "never synced" must not be conflated — `cursor` alone would
94
+ # report 0 for both.
95
+ def bootstrapped?
96
+ !SyncMetadata.get("cursor").nil?
97
+ end
98
+
99
+ # First-sync hydration: windowed current state + cursor in one response.
100
+ # The server captures the cursor BEFORE reading rows, so any overlap
101
+ # with subsequent events re-applies idempotently. Returns true when the
102
+ # snapshot applied.
103
+ def bootstrap
104
+ body = transport.get_json("/bootstrap")
105
+ Array(body["records"]).each { |record| apply_change(record) }
106
+
107
+ # Cursor written last: a crash mid-apply leaves it nil, so the next
108
+ # tick re-bootstraps; upserts make the retry idempotent. A failure
109
+ # never falls through to a cursor-0 poll of the full change log.
110
+ self.cursor = body["cursor"]
111
+ true
112
+ rescue Transport::Error => e
113
+ log_failure("bootstrap", e)
114
+ false
115
+ end
116
+
117
+ # Outbox up. Returns true when anything was sent and applied.
118
+ def drain
119
+ mutations = pending_mutations
120
+ return false if mutations.empty?
121
+
122
+ body = transport.post_json("/mutations", { mutations: mutations })
123
+ apply_results(body["results"])
124
+ true
125
+ rescue Transport::Error => e
126
+ log_failure("drain", e)
127
+ false
128
+ end
129
+
130
+ # Set while something outside the VM holds a tailing stream and feeds
131
+ # events in through apply_events (the service worker does this). Ruby
132
+ # cannot hold that stream itself: each chunk it awaited would suspend the
133
+ # single-flight tick, starving outbox drains for the length of the window.
134
+ attr_writer :streaming
135
+
136
+ def streaming? = !!@streaming
137
+
138
+ # Feeds one chunk of the change stream through the same SseParser the poll
139
+ # path uses, and applies whatever complete events it yields. The worker owns
140
+ # the socket — Ruby awaiting chunks would suspend the sync guard — but the
141
+ # wire format is parsed here, so there is one implementation of it and it is
142
+ # the tested one. A frame split across chunks is buffered until it is whole.
143
+ # Returns the number applied.
144
+ def consume_stream(chunk)
145
+ stream_parser.feed(chunk.to_s)
146
+ return 0 if stream_events.empty?
147
+
148
+ applied = apply_events(stream_events)
149
+ stream_events.clear
150
+ applied
151
+ end
152
+
153
+ # A reconnect starts a new stream: drop any half-received frame from the
154
+ # old one rather than letting its prefix corrupt the first new frame.
155
+ def reset_stream
156
+ @stream_parser = nil
157
+ stream_events.clear
158
+ nil
159
+ end
160
+
161
+ # Applies events delivered from outside — a stream the worker is holding.
162
+ # Idempotent by the same upsert + last-write-wins path a poll uses, so a
163
+ # replayed or overlapping batch is harmless. Returns the number applied.
164
+ def apply_events(events)
165
+ applied = 0
166
+ newest_cursor = nil
167
+
168
+ Array(events).each do |event|
169
+ event = event.deep_stringify_keys
170
+ applied += 1 if apply_change(event)
171
+ # The cursor advances for every event, changed or not: an echo we chose
172
+ # to ignore must not be handed to us again.
173
+ newest_cursor = event["cursor"] || newest_cursor
174
+ end
175
+
176
+ # One cursor write per batch, as poll_changes does: a crash before this
177
+ # re-applies the batch, which the upserts make safe.
178
+ self.cursor = newest_cursor if newest_cursor
179
+ notifier.records_changed if applied.positive?
180
+ applied
181
+ end
182
+
183
+ # Changes down, catch-up style: window=0 asks the server to close the
184
+ # stream right after catch-up instead of tailing a long window. The tick
185
+ # cadence provides liveness; a long-held stream inside the single-flight
186
+ # tick would starve outbox drains and defer notifications.
187
+ #
188
+ # Skipped entirely while a stream is live — that stream is already
189
+ # delivering, and re-polling would only re-fetch what it just applied.
190
+ # Returns true when any remote change was applied.
191
+ def poll_changes
192
+ return false if streaming?
193
+
194
+ changed = false
195
+ last_cursor = nil
196
+
197
+ transport.each_event("/events?after=#{cursor}&window=0") do |event|
198
+ event = event.deep_stringify_keys
199
+ changed = true if apply_change(event)
200
+ last_cursor = event["cursor"] || last_cursor
201
+ end
202
+
203
+ # One cursor write per poll, not per event. A crash before this line
204
+ # re-applies the batch next poll; upserts + LWW make that idempotent.
205
+ self.cursor = last_cursor if last_cursor
206
+ changed
207
+ rescue Transport::Error => e
208
+ log_failure("poll", e)
209
+ changed
210
+ end
211
+
212
+ # On-demand history page for a windowed model. Serves from the local
213
+ # database when the requested page is known-contiguous (see the
214
+ # per-partition low-water mark below); otherwise fetches a keyset page
215
+ # from the server and applies it under applying_remote — idempotent
216
+ # upserts, no outbox rows, no records_changed (the requesting page
217
+ # refreshes itself). Returns {ok:, applied:, has_more:} or an error hash.
218
+ # Callers enter through InstantRecord.fetch_history, which shares the
219
+ # tick's single-flight guard.
220
+ def fetch_history(model, before:, partition: nil, limit: nil)
221
+ window = model.instant_record_sync_window
222
+ raise ArgumentError, "#{model.name} declares no sync_window; fetch_history needs one" unless window
223
+ raise ArgumentError, "#{model.name} windows by #{window.partition_by}; pass partition:" if window.partition_by && partition.nil?
224
+
225
+ limit ||= window.limit
226
+ before_at = Time.parse(before[:created_at].to_s)
227
+ before_id = before[:id].to_s
228
+
229
+ unless InstantRecord.browser?
230
+ return { ok: true, applied: 0, has_more: local_page(window, partition, before_at, before_id, limit + 1).size > limit }
231
+ end
232
+
233
+ if (mark = history_mark(model, partition)) && page_local?(mark, window, partition, before_at, before_id, limit)
234
+ # has_more from the served page, not the partition-wide mark: rows
235
+ # remain below this page when the local probe overflows `limit`, or
236
+ # when the page reaches the contiguity frontier and the server still
237
+ # has older rows (mark[:has_more]). Echoing the mark alone reports
238
+ # "beginning reached" for any mid-history page after the true
239
+ # beginning was loaded.
240
+ local = local_page(window, partition, before_at, before_id, limit + 1)
241
+ return { ok: true, applied: 0, has_more: local.size > limit || mark[:has_more] }
242
+ end
243
+
244
+ body = transport.get_json(history_path(model, partition, before_at, before_id, limit))
245
+ records = Array(body["records"])
246
+ records.each { |record| apply_change(record) }
247
+ extend_history_mark(model, partition, records, has_more: !!body["has_more"])
248
+ { ok: true, applied: records.size, has_more: !!body["has_more"] }
249
+ rescue Transport::Error => e
250
+ log_failure("fetch_history", e)
251
+ { ok: false, applied: 0, has_more: nil, error: e.message }
252
+ end
253
+
254
+ # Server results for a posted batch. Every result drops the outbox row —
255
+ # each one is a durable resolution, not a retry — and what happens to the
256
+ # record depends on which resolution it is:
257
+ # applied -> mark the record synced
258
+ # applied + skipped -> the write lost last-write-wins; reconcile to the
259
+ # value that won, because it did NOT land here
260
+ # rejected -> reconcile to server state
261
+ def apply_results(results)
262
+ Array(results).each do |result|
263
+ result = result.deep_stringify_keys
264
+ mutation = OutboxMutation.find_by(id: result["mutation_id"])
265
+ next unless mutation
266
+
267
+ applying_remote do
268
+ case result["status"]
269
+ when "applied"
270
+ # `skipped` is the server saying it threw this write away to
271
+ # last-write-wins. It is resolved (nothing will retry it) but it
272
+ # never landed, so marking the record synced would badge a
273
+ # discarded write as delivered — the record does not hold what the
274
+ # server holds. Reconcile to the winner, which rides along with the
275
+ # result; the same value also arrives over the change stream, which
276
+ # is what makes doing nothing here look fine until the stream is
277
+ # behind.
278
+ if result["skipped"]
279
+ reconcile_to_server_state(mutation, result)
280
+ else
281
+ mark_synced(mutation, result["version"])
282
+ end
283
+ when "rejected"
284
+ reconcile_rejection(mutation, result)
285
+ end
286
+ mutation.destroy!
287
+ end
288
+ end
289
+ nil
290
+ end
291
+
292
+ # Apply one remote change event (last-write-wins). Returns whether it
293
+ # actually changed anything here — see upsert_from_server.
294
+ def apply_change(event)
295
+ event = event.deep_stringify_keys
296
+ model = InstantRecord.synced_model(event["type"])
297
+ return false unless model
298
+
299
+ applying_remote do
300
+ if event["operation"] == "destroy"
301
+ !!model.find_by(id: event["id"])&.destroy!
302
+ else
303
+ upsert_from_server(model, event["attributes"], event["version"])
304
+ end
305
+ end
306
+ end
307
+
308
+ private
309
+
310
+ # Where the parser drops finished events, drained by consume_stream.
311
+ def stream_events
312
+ @stream_events ||= []
313
+ end
314
+
315
+ def stream_parser
316
+ @stream_parser ||= Transport::SseParser.new { |event| stream_events << event }
317
+ end
318
+
319
+ # Contiguity low-water marks, one per (model, partition), held in
320
+ # VM-session memory only. Row count alone cannot prove a local page has
321
+ # no holes (live updates re-create evicted rows as strays); the mark
322
+ # tracks the oldest boundary of contiguously fetched history plus the
323
+ # server's last has_more answer.
324
+ def history_marks
325
+ @history_marks ||= {}
326
+ end
327
+
328
+ def history_mark(model, partition)
329
+ history_marks[[model.name, partition]]
330
+ end
331
+
332
+ def extend_history_mark(model, partition, records, has_more:)
333
+ key = [model.name, partition]
334
+ oldest = records.map { |r| [Time.parse(r.dig("attributes", "created_at").to_s), r["id"].to_s] }.min
335
+ mark = history_marks[key] ||= { oldest_at: nil, oldest_id: nil }
336
+ mark[:has_more] = has_more
337
+ return unless oldest
338
+ return if mark[:oldest_at] && ([mark[:oldest_at], mark[:oldest_id]] <=> oldest) <= 0
339
+
340
+ mark[:oldest_at], mark[:oldest_id] = oldest
341
+ end
342
+
343
+ # A page is fully local when the beginning of history was already
344
+ # reached, or when `limit` rows older than `before` exist locally within
345
+ # the contiguous region above the mark.
346
+ def page_local?(mark, window, partition, before_at, before_id, limit)
347
+ return true if mark[:has_more] == false
348
+
349
+ rows = local_page(window, partition, before_at, before_id, limit)
350
+ rows.size >= limit && rows.all? { |at, id| ([at, id] <=> [mark[:oldest_at], mark[:oldest_id]]) >= 0 }
351
+ end
352
+
353
+ # Keyset page below (before_at, before_id), newest first, as
354
+ # [created_at, id] tuples.
355
+ def local_page(window, partition, before_at, before_id, limit)
356
+ window
357
+ .keyset_below(at: before_at, id: before_id, partition: partition)
358
+ .limit(limit)
359
+ .pluck(:created_at, window.model.primary_key)
360
+ .map { |at, id| [at.to_time, id.to_s] }
361
+ end
362
+
363
+ def history_path(model, partition, before_at, before_id, limit)
364
+ params = { type: model.name, before_created_at: before_at.utc.iso8601(6), before_id: before_id, limit: limit }
365
+ params[:partition] = partition if partition
366
+ "/records?#{URI.encode_www_form(params)}"
367
+ end
368
+
369
+ def mark_synced(mutation, version)
370
+ model = InstantRecord.synced_model(mutation.record_type)
371
+ record = model&.find_by(id: mutation.record_id)
372
+ return unless record
373
+
374
+ record.update_columns(sync_state: "synced", server_version: version || record.server_version)
375
+ end
376
+
377
+ def reconcile_rejection(mutation, result)
378
+ return if reconcile_to_server_state(mutation, result)
379
+
380
+ # The server never accepted this record (rejected create): remove it.
381
+ InstantRecord.synced_model(mutation.record_type)&.find_by(id: mutation.record_id)&.destroy!
382
+ end
383
+
384
+ # Roll the local record onto the server's own copy of the row, when the
385
+ # result carries one. Returns whether it did — the caller distinguishes
386
+ # "the server has no such row" from "the server has this row".
387
+ #
388
+ # Authoritative: the server told us what it holds in answer to a write of
389
+ # ours it would not take, so its row wins here regardless of timestamps.
390
+ def reconcile_to_server_state(mutation, result)
391
+ model = InstantRecord.synced_model(mutation.record_type)
392
+ return false unless model && result["server_attributes"].present?
393
+
394
+ upsert_from_server(model, result["server_attributes"], result["version"], authoritative: true)
395
+ true
396
+ end
397
+
398
+ # Returns whether the row actually moved. Every accepted write comes back
399
+ # down the change stream, so a client is constantly re-applying its own
400
+ # rows; treating those as changes would announce them, and an announcement
401
+ # reloads any page without an in-place refresh.
402
+ #
403
+ # Client-side last-write-wins, with the tie-break stated on purpose: a
404
+ # remote value must be strictly NEWER to replace a local one, so equal
405
+ # stamps leave the row alone. Equal stamps are the ordinary case, not the
406
+ # exotic one — every write we make comes back with the timestamp we sent —
407
+ # and applying those echoes would re-save the row, announce a change to
408
+ # every tab, and fire the host app's after_save callbacks for a value that
409
+ # never moved. Until both paths carried microseconds, rounding hid this by
410
+ # making an echo look older than its own row; the rule has to be explicit
411
+ # instead of an artifact of truncation.
412
+ #
413
+ # The two sides break ties in opposite directions deliberately:
414
+ # MutationApplier#stale? is strict too, so the server ACCEPTS an
415
+ # equal-stamped client mutation. A tie is therefore settled once, on the
416
+ # server, and never has to be settled again here. (Both sides still compare
417
+ # two machines' wall clocks with no logical clock. Microsecond stamps make
418
+ # a coincidental tie far less likely than millisecond ones did, so the
419
+ # tie-break matters less than it used to — but a client whose clock runs
420
+ # ahead still wins writes it should lose, and that is unchanged.)
421
+ # `authoritative` skips the comparison entirely, for the one case where the
422
+ # server's row is the answer no matter what the clocks say: reconciling a
423
+ # rejection. A rejected update is the ordinary case here, and the server's
424
+ # un-updated row is necessarily OLDER than the optimistic local row that it
425
+ # refused — so last-write-wins would drop it, the outbox row would still be
426
+ # destroyed, and the record would keep the value the server just refused
427
+ # and sit at `pending` forever. Nothing was discarded remotely in that
428
+ # case, so the discard seam does not fire either.
429
+ def upsert_from_server(model, attributes, version, authoritative: false)
430
+ return false unless attributes
431
+
432
+ # Schema skew must not raise here either: a client behind the server is
433
+ # sent columns it has not migrated yet. They are the server's to keep;
434
+ # this runtime converges on the columns it actually has.
435
+ attributes = attributes.slice(*model.column_names)
436
+ record = model.find_or_initialize_by(id: attributes["id"])
437
+
438
+ incoming_at = attributes["updated_at"] && Time.parse(attributes["updated_at"].to_s)
439
+ local_at = record.persisted? ? record.updated_at : nil
440
+
441
+ record.assign_attributes(attributes.except("id", "server_version"))
442
+
443
+ if !authoritative && incoming_at && local_at && incoming_at <= local_at
444
+ # The remote value lost. This is the last moment it exists in this
445
+ # runtime — nothing downstream sees it, which is why models get a seam.
446
+ if (record.changed - BOOKKEEPING_COLUMNS).any?
447
+ model.instant_record_discarded_change_handler&.call(attributes)
448
+ end
449
+ return false
450
+ end
451
+
452
+ record.server_version = version || attributes["server_version"] || 0
453
+ record.sync_state = "synced"
454
+ return false unless record.changed?
455
+
456
+ record.save!(validate: false)
457
+ true
458
+ end
459
+
460
+ def log_failure(phase, error)
461
+ warn "[instant_record] #{phase} failed (offline?): #{error.message}"
462
+ end
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,19 @@
1
+ module InstantRecord
2
+ class Configuration
3
+ # Where the authoritative Rails app's sync engine is mounted. Same-origin
4
+ # relative path by default; set an absolute URL for cross-origin dev
5
+ # setups (e.g. Vite on :5173 talking to Rails on :3000).
6
+ attr_accessor :endpoint
7
+
8
+ # Base delay, in seconds, for retrying an outbox drain that did not get
9
+ # through. It is not a poll cadence: the browser has no heartbeat, because
10
+ # writes trigger their own sync pass and the change stream carries
11
+ # everything inbound. A client with an empty outbox makes no requests at all.
12
+ attr_accessor :sync_interval
13
+
14
+ def initialize
15
+ @endpoint = DEFAULT_MOUNT_PATH
16
+ @sync_interval = 3
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ module InstantRecord
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace InstantRecord
4
+
5
+ config.instant_record = ActiveSupport::OrderedOptions.new
6
+ config.instant_record.mount_path = DEFAULT_MOUNT_PATH
7
+ config.instant_record.build_on_precompile = false
8
+ # How long GET /events tails for new changes before closing (the client
9
+ # reconnects from its cursor). Clients may request less via ?window=.
10
+ config.instant_record.sse_window_seconds = 25.0
11
+
12
+ initializer "instant_record.migrations" do |app|
13
+ unless app.root == root
14
+ config.paths["db/migrate"].expanded.each do |path|
15
+ app.config.paths["db/migrate"] << path unless app.config.paths["db/migrate"].include?(path)
16
+ end
17
+ end
18
+ end
19
+
20
+ # `ActiveRecord::Migrator.migrations_paths` stays as Rails ships it — the
21
+ # relative "db/migrate", which resolves only because a server's working
22
+ # directory happens to be Rails.root. The browser VM starts at "/", so
23
+ # nothing there can be fixed by an initializer: reconciling
24
+ # `schema_migrations` before migrating needs a database connection, which no
25
+ # initializer is guaranteed. That work is InstantRecord.prepare_database! —
26
+ # the boot path's replacement for `prepare_all`, see LocalSchema. Leaving the
27
+ # global default alone is deliberate: it keeps `prepare_all`'s own internal
28
+ # migrate a no-op, so the reconciliation runs before any migration does.
29
+
30
+ # One gap in the PGlite adapter has to be closed before any of that can run
31
+ # a migration; see instant_record/pglite_compat.
32
+ initializer "instant_record.pglite_compat" do
33
+ next unless InstantRecord.browser?
34
+
35
+ require "instant_record/pglite_compat"
36
+ end
37
+
38
+ # Auto-mount the sync endpoints on the server. Set
39
+ # `config.instant_record.mount_path = false` to mount manually,
40
+ # or to another string to change the path.
41
+ initializer "instant_record.mount" do |app|
42
+ next if InstantRecord.browser?
43
+
44
+ mount_path = app.config.instant_record.mount_path
45
+ next unless mount_path
46
+
47
+ app.routes.append do
48
+ mount InstantRecord::Engine => mount_path
49
+ end
50
+ end
51
+ end
52
+ end