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