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,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
@@ -0,0 +1,3 @@
1
+ module InstantRecord
2
+ VERSION = "0.1.0"
3
+ end