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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/app/controllers/instant_record/bootstraps_controller.rb +34 -0
- data/app/controllers/instant_record/events_controller.rb +59 -0
- data/app/controllers/instant_record/mutations_controller.rb +21 -0
- data/app/controllers/instant_record/records_controller.rb +46 -0
- data/app/models/instant_record/applied_mutation.rb +9 -0
- data/app/models/instant_record/change.rb +30 -0
- data/app/models/instant_record/mutation_applier.rb +133 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260721000002_create_instant_record_outbox.rb +12 -0
- data/db/migrate/20260721000003_create_instant_record_sync_metadata.rb +9 -0
- data/db/migrate/20260721000101_create_instant_record_changes.rb +13 -0
- data/db/migrate/20260721000102_create_instant_record_applied_mutations.rb +10 -0
- data/lib/generators/instant_record/install/install_generator.rb +53 -0
- data/lib/generators/instant_record/install/templates/database.js +32 -0
- data/lib/generators/instant_record/install/templates/index.html +69 -0
- data/lib/generators/instant_record/install/templates/rails.sw.js +750 -0
- data/lib/instant_record/client/notifier.rb +23 -0
- data/lib/instant_record/client/transport.rb +90 -0
- data/lib/instant_record/client.rb +465 -0
- data/lib/instant_record/configuration.rb +19 -0
- data/lib/instant_record/engine.rb +52 -0
- data/lib/instant_record/local_schema.rb +75 -0
- data/lib/instant_record/outbox_mutation.rb +24 -0
- data/lib/instant_record/pglite_compat.rb +40 -0
- data/lib/instant_record/runtime_scoped.rb +21 -0
- data/lib/instant_record/sync_metadata.rb +16 -0
- data/lib/instant_record/sync_window.rb +82 -0
- data/lib/instant_record/syncable.rb +106 -0
- data/lib/instant_record/version.rb +3 -0
- data/lib/instant_record.rb +356 -0
- data/lib/tasks/instant_record.rake +53 -0
- 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
|