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,75 @@
|
|
|
1
|
+
module InstantRecord
|
|
2
|
+
# Bringing a local database up to the app's current schema, at boot.
|
|
3
|
+
#
|
|
4
|
+
# `DatabaseTasks.prepare_all` is the whole story on a server: a process starts
|
|
5
|
+
# in Rails.root, so the relative "db/migrate" that ActiveRecord::Migrator
|
|
6
|
+
# defaults to resolves. In the browser the VM's working directory is "/" while
|
|
7
|
+
# the app is mounted at "/rails", so that path finds nothing: the migrator sees
|
|
8
|
+
# zero migrations, reports nothing pending, and `prepare_all` is a silent
|
|
9
|
+
# no-op. A returning visitor therefore keeps whatever schema their local
|
|
10
|
+
# database was created with, and the first page touching a table added since
|
|
11
|
+
# fails with "relation does not exist" — which is how two demos broke for
|
|
12
|
+
# anyone who had booted the app before they existed.
|
|
13
|
+
#
|
|
14
|
+
# Pointing the migrator at absolute paths is not enough, and alone it is worse
|
|
15
|
+
# than the no-op. These databases were built by `load_schema`, and its
|
|
16
|
+
# `assume_migrated_upto_version` could only record the versions it could find —
|
|
17
|
+
# which, with the paths broken, was the schema's own version and nothing else.
|
|
18
|
+
# Make every migration discoverable and they all look pending, so boot replays
|
|
19
|
+
# `create_table :issues` against a database that already has it, the install
|
|
20
|
+
# raises, and the worker never activates.
|
|
21
|
+
#
|
|
22
|
+
# So the versions are reconciled first, by the rule
|
|
23
|
+
# `assume_migrated_upto_version` would have used: db/schema.rb describes every
|
|
24
|
+
# migration up to its own version, so a migration older than the highest
|
|
25
|
+
# version this database has recorded is already in it, and gets stamped rather
|
|
26
|
+
# than run. Anything newer is genuinely pending, and is migrated for real.
|
|
27
|
+
#
|
|
28
|
+
# That inherits one property from `assume_migrated_upto_version`: a migration
|
|
29
|
+
# back-dated below a version this database already has is taken as applied,
|
|
30
|
+
# not run. On a server `db:migrate` would run it. Here there is no history to
|
|
31
|
+
# consult — only a schema that was loaded whole — so the version order is the
|
|
32
|
+
# only evidence there is.
|
|
33
|
+
module LocalSchema
|
|
34
|
+
class << self
|
|
35
|
+
# The browser runtime's schema step, in place of a bare `prepare_all`.
|
|
36
|
+
def prepare!
|
|
37
|
+
ActiveRecord::Tasks::DatabaseTasks.prepare_all
|
|
38
|
+
|
|
39
|
+
# On a server the working directory already makes migrations findable,
|
|
40
|
+
# and the stamping below would be wrong there: a back-dated migration on
|
|
41
|
+
# a real migration history is genuinely pending, not implied by a schema
|
|
42
|
+
# load that never happened.
|
|
43
|
+
return unless InstantRecord.browser?
|
|
44
|
+
|
|
45
|
+
catch_up!(migration_paths)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Reconcile, then migrate. Takes the paths explicitly because the whole
|
|
49
|
+
# bug is a path that only resolves from the right working directory.
|
|
50
|
+
def catch_up!(paths)
|
|
51
|
+
context = ActiveRecord::MigrationContext.new(paths)
|
|
52
|
+
stamp_migrations_the_loaded_schema_already_contains(context)
|
|
53
|
+
context.migrate
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Absolute, and the app's own configured paths, so the engine's migrations
|
|
59
|
+
# travel with the app's (see the "instant_record.migrations" initializer).
|
|
60
|
+
def migration_paths
|
|
61
|
+
Rails.application.config.paths["db/migrate"].existent
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stamp_migrations_the_loaded_schema_already_contains(context)
|
|
65
|
+
recorded = context.get_all_versions
|
|
66
|
+
baseline = recorded.max
|
|
67
|
+
return unless baseline
|
|
68
|
+
|
|
69
|
+
(context.migrations.map(&:version) - recorded)
|
|
70
|
+
.select { |version| version < baseline }
|
|
71
|
+
.each { |version| context.schema_migration.create_version(version) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module InstantRecord
|
|
2
|
+
# Browser-local durable outbox. One row per write, created in the same
|
|
3
|
+
# transaction as the record change. Drained in insertion order on sync.
|
|
4
|
+
class OutboxMutation < ActiveRecord::Base
|
|
5
|
+
self.table_name = "instant_record_outbox"
|
|
6
|
+
|
|
7
|
+
serialize :changes_payload, coder: JSON
|
|
8
|
+
|
|
9
|
+
before_create { self.id ||= SecureRandom.uuid }
|
|
10
|
+
|
|
11
|
+
scope :ordered, -> { order(:created_at, :id) }
|
|
12
|
+
|
|
13
|
+
def as_mutation
|
|
14
|
+
{
|
|
15
|
+
id: id,
|
|
16
|
+
record_type: record_type,
|
|
17
|
+
record_id: record_id,
|
|
18
|
+
operation: operation,
|
|
19
|
+
changes: changes_payload,
|
|
20
|
+
base_version: base_version
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# DDL in the browser runtime, made possible.
|
|
2
|
+
#
|
|
3
|
+
# wasmify-rails' PGlite adapter inherits Rails' PostgreSQL statement pool, whose
|
|
4
|
+
# `dealloc` asks the raw connection whether it is still usable before sending a
|
|
5
|
+
# DEALLOCATE:
|
|
6
|
+
#
|
|
7
|
+
# if (conn = ...) && conn.status == PG::CONNECTION_OK
|
|
8
|
+
#
|
|
9
|
+
# Neither piece exists on the wasm side — the interface has no `status`, and the
|
|
10
|
+
# pg stub defines transaction statuses but not connection ones. Nothing hits that
|
|
11
|
+
# path until the pool is cleared, and the pool is cleared by DDL, so every
|
|
12
|
+
# migration in the browser died with `undefined method 'status'`: the boot
|
|
13
|
+
# catch-up (see InstantRecord::LocalSchema) and the demo that ships a migration
|
|
14
|
+
# on demand both.
|
|
15
|
+
#
|
|
16
|
+
# The fix is to say what is true rather than to make the deallocation work.
|
|
17
|
+
# PGlite's `prepare` only records the SQL in a Ruby-side map; nothing is prepared
|
|
18
|
+
# on the database, so there is nothing to deallocate, and a raw connection that
|
|
19
|
+
# does not answer PG::CONNECTION_OK is exactly how the pool is told to skip it.
|
|
20
|
+
# That covers every route into `dealloc` — a cleared cache, a deleted statement,
|
|
21
|
+
# and the eviction once the pool passes its limit.
|
|
22
|
+
#
|
|
23
|
+
# This belongs upstream in wasmify-rails; it lives here so the runtime this gem
|
|
24
|
+
# ships can run a migration at all.
|
|
25
|
+
require "active_record/connection_adapters/pglite_adapter"
|
|
26
|
+
|
|
27
|
+
module PG
|
|
28
|
+
CONNECTION_OK = 0 unless defined?(CONNECTION_OK)
|
|
29
|
+
CONNECTION_BAD = 1 unless defined?(CONNECTION_BAD)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module ActiveRecord
|
|
33
|
+
module ConnectionAdapters
|
|
34
|
+
class PGliteAdapter
|
|
35
|
+
class ExternalInterface
|
|
36
|
+
def status = PG::CONNECTION_BAD
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module InstantRecord
|
|
2
|
+
# Class-level blocks that only exist in one runtime. Syncable models get
|
|
3
|
+
# these automatically; any other class (controllers, jobs, POROs) opts in:
|
|
4
|
+
#
|
|
5
|
+
# class IssuesController < ApplicationController
|
|
6
|
+
# extend InstantRecord::RuntimeScoped
|
|
7
|
+
#
|
|
8
|
+
# server_only do
|
|
9
|
+
# before_action :authenticate_user! # sessions exist only on the server
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
module RuntimeScoped
|
|
13
|
+
def server_only(&block)
|
|
14
|
+
class_eval(&block) unless InstantRecord.browser?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def browser_only(&block)
|
|
18
|
+
class_eval(&block) if InstantRecord.browser?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module InstantRecord
|
|
2
|
+
# Browser-local key/value store; holds the SSE cursor across reloads.
|
|
3
|
+
class SyncMetadata < ActiveRecord::Base
|
|
4
|
+
self.table_name = "instant_record_sync_metadata"
|
|
5
|
+
self.primary_key = "key"
|
|
6
|
+
|
|
7
|
+
def self.get(key)
|
|
8
|
+
find_by(key: key)&.value
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.set(key, value)
|
|
12
|
+
record = find_or_initialize_by(key: key)
|
|
13
|
+
record.update!(value: value.to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module InstantRecord
|
|
2
|
+
# A bounded sync window declared on a Syncable model: the newest `limit`
|
|
3
|
+
# rows, per partition when partition_by is set, ordered by (created_at, id).
|
|
4
|
+
# Bootstrap serializes in_window, history pages walk below it, and the
|
|
5
|
+
# browser client evicts beyond_window at boot.
|
|
6
|
+
class SyncWindow
|
|
7
|
+
attr_reader :model, :limit, :partition_by
|
|
8
|
+
|
|
9
|
+
def initialize(model, limit:, partition_by: nil)
|
|
10
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
11
|
+
raise ArgumentError, "sync_window limit must be a positive integer (got #{limit.inspect})"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
@model = model
|
|
15
|
+
@limit = limit
|
|
16
|
+
@partition_by = partition_by&.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Rows inside the window (the newest `limit` per partition).
|
|
20
|
+
def in_window
|
|
21
|
+
@model.where("#{quoted_primary_key} IN (#{ranked_ids_sql("<=")})")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Rows outside the window — eviction's target. An extra `scope` narrows
|
|
25
|
+
# further (e.g. excluding sync_state "pending").
|
|
26
|
+
def beyond_window(scope = @model.all)
|
|
27
|
+
scope.where("#{quoted_primary_key} IN (#{ranked_ids_sql(">")})")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Keyset page scope: rows strictly below the (created_at, id) tuple within
|
|
31
|
+
# one partition, newest first. Shared by the records endpoint (server) and
|
|
32
|
+
# the client's local-page checks — one predicate, no OFFSET.
|
|
33
|
+
def keyset_below(at:, id:, partition: nil)
|
|
34
|
+
pk = @model.primary_key
|
|
35
|
+
scope = @partition_by ? @model.where(@partition_by => partition) : @model.all
|
|
36
|
+
scope
|
|
37
|
+
.where("created_at < :at OR (created_at = :at AND #{pk} < :id)", at: at, id: id)
|
|
38
|
+
.order(created_at: :desc, pk => :desc)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# ROW_NUMBER() runs identically on Postgres, PGlite, and SQLite >= 3.25 —
|
|
44
|
+
# the three engines the same model file can find itself on.
|
|
45
|
+
def ranked_ids_sql(comparison)
|
|
46
|
+
validate_columns!
|
|
47
|
+
connection = @model.connection
|
|
48
|
+
table = connection.quote_table_name(@model.table_name)
|
|
49
|
+
created_at = connection.quote_column_name("created_at")
|
|
50
|
+
over = [partition_clause(connection), "ORDER BY #{created_at} DESC, #{quoted_primary_key} DESC"].compact.join(" ")
|
|
51
|
+
|
|
52
|
+
<<~SQL.squish
|
|
53
|
+
SELECT #{quoted_primary_key} FROM (
|
|
54
|
+
SELECT #{quoted_primary_key}, ROW_NUMBER() OVER (#{over}) AS instant_record_rank
|
|
55
|
+
FROM #{table}
|
|
56
|
+
) instant_record_ranked
|
|
57
|
+
WHERE instant_record_rank #{comparison} #{@limit}
|
|
58
|
+
SQL
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def partition_clause(connection)
|
|
62
|
+
@partition_by && "PARTITION BY #{connection.quote_column_name(@partition_by)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def quoted_primary_key
|
|
66
|
+
@model.connection.quote_column_name(@model.primary_key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Deferred to first use: at declaration time the table may not exist yet
|
|
70
|
+
# (class load precedes migrations).
|
|
71
|
+
def validate_columns!
|
|
72
|
+
return if @validated
|
|
73
|
+
|
|
74
|
+
%w[created_at].concat([@partition_by].compact).each do |column|
|
|
75
|
+
unless @model.column_names.include?(column)
|
|
76
|
+
raise ArgumentError, "sync_window column #{column.inspect} does not exist on #{@model.table_name}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
@validated = true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module InstantRecord
|
|
4
|
+
# Makes a model local-first syncable. The same file loads in both runtimes:
|
|
5
|
+
# in the browser (ruby.wasm) every write also records an outbox mutation in
|
|
6
|
+
# the same local transaction; on the server the concern only provides the
|
|
7
|
+
# shared column conventions (uuid ids, server_version, sync_state).
|
|
8
|
+
module Syncable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
extend InstantRecord::RuntimeScoped
|
|
13
|
+
|
|
14
|
+
before_create :assign_instant_record_uuid
|
|
15
|
+
|
|
16
|
+
# Guarded at call time, not include time, so the same class works in
|
|
17
|
+
# both runtimes and tests can stub the runtime without re-including.
|
|
18
|
+
# after_create/update/destroy run INSIDE the wrapping transaction
|
|
19
|
+
# (unlike after_commit), which is what gives us write+outbox atomicity.
|
|
20
|
+
before_save { self.sync_state = "pending" if instant_record_local_write? }
|
|
21
|
+
after_create { record_outbox_mutation("create") if instant_record_local_write? }
|
|
22
|
+
after_update { record_outbox_mutation("update") if instant_record_local_write? }
|
|
23
|
+
after_destroy { record_outbox_mutation("destroy") if instant_record_local_write? }
|
|
24
|
+
|
|
25
|
+
# Server-originated writes (jobs, console, seeds) are change-logged so
|
|
26
|
+
# they stream to clients like any other change. Client mutations are
|
|
27
|
+
# excluded — MutationApplier versions and logs those itself.
|
|
28
|
+
before_create { self.server_version = 1 if instant_record_server_write? }
|
|
29
|
+
before_update { self.server_version += 1 if instant_record_server_write? }
|
|
30
|
+
after_create { record_server_change("create") if instant_record_server_write? }
|
|
31
|
+
after_update { record_server_change("update") if instant_record_server_write? }
|
|
32
|
+
after_destroy { record_server_change("destroy") if instant_record_server_write? }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class_methods do
|
|
36
|
+
def instant_record_syncable? = true
|
|
37
|
+
|
|
38
|
+
# Declare a bounded sync window: only the newest `limit` rows (per
|
|
39
|
+
# `partition_by` value when given) sync to fresh clients; older rows
|
|
40
|
+
# arrive on demand via InstantRecord.fetch_history and the browser
|
|
41
|
+
# evicts beyond the window at boot. Models without a declaration keep
|
|
42
|
+
# full sync.
|
|
43
|
+
def sync_window(limit:, partition_by: nil)
|
|
44
|
+
@instant_record_sync_window = InstantRecord::SyncWindow.new(self, limit: limit, partition_by: partition_by)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def instant_record_sync_window
|
|
48
|
+
@instant_record_sync_window
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Observe a value from the server that last-write-wins threw away. The
|
|
52
|
+
# block runs with the losing attribute hash at the moment it is dropped,
|
|
53
|
+
# which is the only moment it exists in this runtime — the gem assigns
|
|
54
|
+
# nothing, fires no callback, and keeps no copy.
|
|
55
|
+
def on_discarded_change(&block)
|
|
56
|
+
@instant_record_discarded_change_handler = block
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def instant_record_discarded_change_handler
|
|
60
|
+
@instant_record_discarded_change_handler
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def instant_record_local_write?
|
|
67
|
+
InstantRecord.browser? && !InstantRecord::Client.applying_remote?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def instant_record_server_write?
|
|
71
|
+
!InstantRecord.browser? && !InstantRecord.applying_client_mutation?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def assign_instant_record_uuid
|
|
75
|
+
self.id ||= SecureRandom.uuid
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def record_outbox_mutation(operation)
|
|
79
|
+
changes_payload =
|
|
80
|
+
case operation
|
|
81
|
+
when "create" then InstantRecord.wire_attributes(attributes)
|
|
82
|
+
when "update" then InstantRecord.wire_attributes(saved_changes.transform_values(&:last))
|
|
83
|
+
when "destroy" then {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
InstantRecord::OutboxMutation.create!(
|
|
87
|
+
record_type: self.class.name,
|
|
88
|
+
record_id: id,
|
|
89
|
+
operation: operation,
|
|
90
|
+
changes_payload: changes_payload,
|
|
91
|
+
base_version: self[:server_version] || 0
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Mirrors MutationApplier's log_change/log_destroy payload shapes.
|
|
96
|
+
def record_server_change(operation)
|
|
97
|
+
InstantRecord::Change.create!(
|
|
98
|
+
record_type: self.class.name,
|
|
99
|
+
record_id: id,
|
|
100
|
+
operation: operation,
|
|
101
|
+
version: operation == "destroy" ? 0 : self[:server_version],
|
|
102
|
+
attributes_payload: operation == "destroy" ? {} : InstantRecord.wire_attributes(attributes)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|