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,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,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,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…</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>
|