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,133 @@
1
+ module InstantRecord
2
+ # Applies one client mutation authoritatively: record write, version bump,
3
+ # change-log row, and idempotency-ledger row, in one transaction. This is
4
+ # the server half of the sync protocol; the HTTP layer above it is thin.
5
+ class MutationApplier
6
+ def self.apply(mutation)
7
+ new(mutation).apply
8
+ end
9
+
10
+ def initialize(mutation)
11
+ @mutation = mutation
12
+ @changes = (mutation[:changes] || {}).to_h
13
+ end
14
+
15
+ # Replayed mutation ids return their original result without re-applying.
16
+ def apply
17
+ if (existing = AppliedMutation.find_by(mutation_id: @mutation[:id]))
18
+ return existing.result_payload.symbolize_keys
19
+ end
20
+
21
+ model = InstantRecord.synced_model(@mutation[:record_type])
22
+ return record_result(status: "rejected", reason: "unknown record type") unless model
23
+
24
+ result = ActiveRecord::Base.transaction do
25
+ InstantRecord.applying_client_mutation { perform(model) }
26
+ end
27
+ record_result(**result)
28
+ rescue ActiveRecord::RecordInvalid => e
29
+ record_result(status: "rejected", reason: e.record.errors.full_messages.to_sentence,
30
+ server_attributes: server_attributes_for(model))
31
+ rescue ActiveRecord::RecordNotUnique
32
+ # A create whose id already exists (e.g. a client replaying rows the
33
+ # server already has). Rejecting lets the client roll back its local
34
+ # copy instead of retrying the poisoned mutation forever; the server's
35
+ # row comes back down through the change stream.
36
+ record_result(status: "rejected", reason: "id already exists",
37
+ server_attributes: server_attributes_for(model))
38
+ rescue ActiveModel::UnknownAttributeError => e
39
+ # Schema skew: this client is a migration ahead of this server and sent a
40
+ # column that does not exist here. Rejecting is the only outcome that
41
+ # cannot jam the queue. Raising 500s the entire batch, and because the
42
+ # client keeps the row on a transport error it would retry the same
43
+ # poisoned mutation forever, blocking every write queued behind it.
44
+ # Slicing the unknown column off instead would answer "applied" for a
45
+ # write the server only partly stored — silent data loss the client has no
46
+ # way to notice. A surfaced rejection loses the same write loudly, rolls
47
+ # the local record back to what the server actually holds, and leaves the
48
+ # queue draining. Deploy order (server first) remains the real defence.
49
+ record_result(status: "rejected", reason: e.message,
50
+ server_attributes: server_attributes_for(model))
51
+ end
52
+
53
+ private
54
+
55
+ def perform(model)
56
+ case @mutation[:operation]
57
+ when "create"
58
+ record = model.new(@changes.merge("id" => @mutation[:record_id]))
59
+ record.server_version = 1
60
+ record.sync_state = "synced"
61
+ record.save!
62
+ log_change(record, "create")
63
+ { status: "applied", version: record.server_version }
64
+ when "update"
65
+ record = model.find_by(id: @mutation[:record_id])
66
+ return { status: "rejected", reason: "record not found" } unless record
67
+
68
+ if stale?(record)
69
+ # Last-write-wins: an older concurrent write is acknowledged (there is
70
+ # nothing to retry) but skipped. The row that won travels back with the
71
+ # result, because a client told only "applied" badges the write it just
72
+ # lost as synced. See Client#apply_results.
73
+ { status: "applied", version: record.server_version, skipped: true,
74
+ server_attributes: InstantRecord.wire_attributes(record.attributes) }
75
+ else
76
+ record.assign_attributes(@changes.except("id", "server_version"))
77
+ record.server_version += 1
78
+ record.sync_state = "synced"
79
+ record.save!
80
+ log_change(record, "update")
81
+ { status: "applied", version: record.server_version }
82
+ end
83
+ when "destroy"
84
+ record = model.find_by(id: @mutation[:record_id])
85
+ record&.destroy!
86
+ log_destroy(model)
87
+ { status: "applied" }
88
+ else
89
+ { status: "rejected", reason: "unknown operation" }
90
+ end
91
+ end
92
+
93
+ def stale?(record)
94
+ incoming = @changes["updated_at"]
95
+ incoming.present? && record.updated_at.present? && Time.zone.parse(incoming.to_s) < record.updated_at
96
+ end
97
+
98
+ def log_change(record, operation)
99
+ Change.create!(
100
+ record_type: record.class.name,
101
+ record_id: record.id,
102
+ operation: operation,
103
+ version: record.server_version,
104
+ attributes_payload: InstantRecord.wire_attributes(record.attributes)
105
+ )
106
+ end
107
+
108
+ def log_destroy(model)
109
+ Change.create!(
110
+ record_type: model.name,
111
+ record_id: @mutation[:record_id],
112
+ operation: "destroy",
113
+ version: 0,
114
+ attributes_payload: {}
115
+ )
116
+ end
117
+
118
+ def server_attributes_for(model)
119
+ record = model&.find_by(id: @mutation[:record_id])
120
+ InstantRecord.wire_attributes(record.attributes) if record
121
+ end
122
+
123
+ # create_or_find_by leans on the unique index: a concurrent duplicate
124
+ # returns the already-recorded result instead of applying twice.
125
+ def record_result(**result)
126
+ payload = result.merge(mutation_id: @mutation[:id])
127
+ record = AppliedMutation.create_or_find_by(mutation_id: @mutation[:id]) do |applied|
128
+ applied.result_payload = payload
129
+ end
130
+ record.result_payload.symbolize_keys
131
+ end
132
+ end
133
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ InstantRecord::Engine.routes.draw do
2
+ post "mutations", to: "mutations#create"
3
+ match "mutations", to: "mutations#preflight", via: :options
4
+ get "events", to: "events#index"
5
+ get "bootstrap", to: "bootstraps#show"
6
+ get "records", to: "records#index"
7
+ end
@@ -0,0 +1,12 @@
1
+ class CreateInstantRecordOutbox < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :instant_record_outbox, id: :string do |t|
4
+ t.string :record_type, null: false
5
+ t.string :record_id, null: false
6
+ t.string :operation, null: false
7
+ t.text :changes_payload
8
+ t.integer :base_version, null: false, default: 0
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class CreateInstantRecordSyncMetadata < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :instant_record_sync_metadata, id: false do |t|
4
+ t.string :key, null: false, primary_key: true
5
+ t.string :value
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ class CreateInstantRecordChanges < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :instant_record_changes do |t|
4
+ t.string :record_type, null: false
5
+ t.string :record_id, null: false
6
+ t.string :operation, null: false
7
+ t.integer :version, null: false, default: 0
8
+ t.text :attributes_payload
9
+ t.datetime :created_at, null: false
10
+ end
11
+ add_index :instant_record_changes, [:record_type, :record_id]
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ class CreateInstantRecordAppliedMutations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :instant_record_applied_mutations do |t|
4
+ t.string :mutation_id, null: false
5
+ t.text :result_payload
6
+ t.datetime :created_at, null: false
7
+ end
8
+ add_index :instant_record_applied_mutations, :mutation_id, unique: true
9
+ end
10
+ end
@@ -0,0 +1,53 @@
1
+ require "rails/generators"
2
+
3
+ module InstantRecord
4
+ module Generators
5
+ # Prepares a Rails app for InstantRecord's browser runtime:
6
+ # wasmify environment, PGlite database config, and a PWA shell
7
+ # wired with the InstantRecord sync driver.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def run_wasmify_install
12
+ rake "wasmify:install"
13
+ end
14
+
15
+ def generate_pwa_shell
16
+ rake "wasmify:pwa"
17
+ end
18
+
19
+ def configure_pglite_database
20
+ gsub_file "config/database.yml", '{ "nulldb" }', '{ "pglite" }'
21
+ inject_into_file "config/database.yml", " js_interface: pglite4rails\n",
22
+ after: /^wasm:\n adapter: .*\n/
23
+ end
24
+
25
+ def install_sync_driver
26
+ copy_file "rails.sw.js", "pwa/rails.sw.js", force: true
27
+ copy_file "database.js", "pwa/database.js", force: true
28
+ # Auto-booting splash: registers the worker and reloads into the app.
29
+ # (wasmify's boot.html stays available as a debug page.)
30
+ copy_file "index.html", "pwa/index.html", force: true
31
+ end
32
+
33
+ def use_pglite_in_pwa
34
+ gsub_file "pwa/package.json", /"wasmify-rails": "\^[\d.]+"/, '"wasmify-rails": "^0.2.3"'
35
+ gsub_file "pwa/package.json", %r{"@sqlite\.org/sqlite-wasm": "[^"]+"}, '"@electric-sql/pglite": "^0.3.0"'
36
+ gsub_file "pwa/vite.config.js", %r{exclude: \["@sqlite\.org/sqlite-wasm"\]}, 'exclude: ["@electric-sql/pglite"]'
37
+ end
38
+
39
+ def show_next_steps
40
+ say <<~MSG
41
+
42
+ InstantRecord is set up. Next:
43
+
44
+ 1. Mark the gems your app needs in the browser with `group: [:default, :wasm]` in the Gemfile
45
+ 2. bin/rails db:migrate # engine tables (outbox, change log, ...)
46
+ 3. bin/rails instant_record:build # compile + pack the browser bundle
47
+ 4. cd pwa && yarn install && yarn dev # boot it
48
+
49
+ MSG
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ import { PGlite, types } from "@electric-sql/pglite";
2
+
3
+ // Timestamps come back as raw Postgres strings rather than JavaScript Dates.
4
+ //
5
+ // Two things go wrong otherwise. Rails stores UTC wall-clock in a `timestamp
6
+ // without time zone` column, and the default parser builds a Date from that
7
+ // naive value as if it were *local* time — so reading it back shifts every
8
+ // timestamp by the machine's UTC offset. And a Date stringifies without
9
+ // sub-second precision, which silently truncates microseconds.
10
+ //
11
+ // Microseconds matter beyond display: a windowed model's history pages are
12
+ // keyset-paginated on (created_at, id), so a truncated cursor cannot be
13
+ // round-tripped into a query and scroll-up stalls. Handing Ruby the string lets
14
+ // ActiveRecord's own DateTime cast do the work, which reads it as UTC and keeps
15
+ // full precision.
16
+ const RAW_TIMESTAMPS = {
17
+ [types.DATE]: (value) => value,
18
+ [types.TIMESTAMP]: (value) => value,
19
+ [types.TIMESTAMPTZ]: (value) => value,
20
+ };
21
+
22
+ export const setupPGliteDatabase = async () => {
23
+ // IndexedDB persistence: survives reloads and service worker restarts.
24
+ // relaxedDurability returns query results before the IndexedDB flush completes.
25
+ const db = await PGlite.create("idb://instant_record", {
26
+ relaxedDurability: true,
27
+ parsers: RAW_TIMESTAMPS,
28
+ });
29
+
30
+ console.log("Running PGlite (Postgres in Wasm)");
31
+ return db;
32
+ };
@@ -0,0 +1,69 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>InstantRecord</title>
7
+ <style>
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
10
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
11
+ min-height: 100vh; margin: 0; background: #fafafa; color: #333;
12
+ }
13
+ .pulse {
14
+ width: 14px; height: 14px; border-radius: 50%; background: #34c759;
15
+ animation: pulse 1.2s ease-in-out infinite; margin-bottom: 1.25rem;
16
+ }
17
+ @keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.6); opacity: 0.4; } }
18
+ h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.5rem; }
19
+ #status { font-size: 0.85rem; color: #888; min-height: 1.2em; max-width: 32rem; text-align: center; }
20
+ .hint { font-size: 0.75rem; color: #bbb; margin-top: 2rem; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div class="pulse"></div>
25
+ <h1>InstantRecord</h1>
26
+ <p id="status">Starting the local runtime&hellip;</p>
27
+ <p class="hint">First visit compiles Ruby for your browser (~60MB, cached afterwards)</p>
28
+
29
+ <!-- Classic (non-module) script on purpose: served verbatim by both the
30
+ static server and the Vite build, no bundling required. -->
31
+ <script>
32
+ (async () => {
33
+ const status = document.getElementById("status");
34
+
35
+ if (!("serviceWorker" in navigator)) {
36
+ status.textContent = "This browser does not support service workers.";
37
+ return;
38
+ }
39
+
40
+ // If a previous visit already installed the app, the service worker
41
+ // will take over this URL on reload.
42
+ if (navigator.serviceWorker.controller) {
43
+ location.reload();
44
+ return;
45
+ }
46
+
47
+ navigator.serviceWorker.addEventListener("message", (event) => {
48
+ if (event.data && event.data.type === "progress" && event.data.step) {
49
+ status.textContent = event.data.step;
50
+ }
51
+ });
52
+
53
+ try {
54
+ await navigator.serviceWorker.register("/rails.sw.js", { scope: "/", type: "module" });
55
+ } catch (error) {
56
+ status.textContent = "Could not start: " + error.message;
57
+ return;
58
+ }
59
+
60
+ // `ready` resolves once install finishes — and install boots the whole
61
+ // Rails VM, so this is "the app is actually usable".
62
+ await navigator.serviceWorker.ready;
63
+
64
+ status.textContent = "Ready — opening the app";
65
+ location.reload(); // now served by Rails running in this browser
66
+ })();
67
+ </script>
68
+ </body>
69
+ </html>