funicular 0.4.0 → 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -16,8 +16,9 @@ module Funicular
16
16
  if (window.funicularFormDataUpload) {
17
17
  return;
18
18
  }
19
- // FormData upload helper
20
- window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
19
+ // FormData upload helper. The CSRF token is read fresh per
20
+ // request (Rails rotates it), matching Funicular::HTTP.
21
+ window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId, method) {
21
22
  const formData = new FormData();
22
23
  for (const [key, value] of Object.entries(fieldsObj)) {
23
24
  formData.append(key, String(value));
@@ -28,10 +29,17 @@ module Funicular
28
29
  formData.append(fileFieldName, file);
29
30
  }
30
31
  }
31
- return fetch(url, {
32
- method: 'PATCH',
32
+ const options = {
33
+ method: method || 'PATCH',
34
+ credentials: 'include',
33
35
  body: formData
34
- }).then(response => response.json());
36
+ };
37
+ const meta = document.querySelector('meta[name="csrf-token"]');
38
+ const token = meta && meta.getAttribute('content');
39
+ if (token) {
40
+ options.headers = { 'X-CSRF-Token': token };
41
+ }
42
+ return fetch(url, options).then(response => response.json());
35
43
  };
36
44
  console.log('Funicular helpers mounted');
37
45
  })();
@@ -83,8 +91,9 @@ module Funicular
83
91
  # @param fields [Hash] Form fields to include
84
92
  # @param file_field [String] Name of the file field
85
93
  # @param file [JS::Object] File object from input element
94
+ # @param method [String] HTTP method; PATCH is the historical default
86
95
  # @param block [Proc] Callback with response data
87
- def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
96
+ def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, method: "PATCH", &block)
88
97
  # Store file and callback ID
89
98
  @callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
90
99
  callback_id = _ = nil
@@ -116,7 +125,8 @@ module Funicular
116
125
  '#{url}',
117
126
  fieldsObj,
118
127
  #{file_field ? "'#{file_field}'" : 'null'},
119
- fileRefId
128
+ fileRefId,
129
+ #{JSON.generate(method.to_s)}
120
130
  ).then(function(data) {
121
131
  // Store as JSON string for easy Ruby parsing
122
132
  window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
data/mrblib/funicular.rb CHANGED
@@ -18,7 +18,7 @@ module Funicular
18
18
  # Guard against redefinition: when the mrblib runtime is loaded into a
19
19
  # CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
20
20
  # VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
21
- VERSION = '0.4.0' unless Funicular.const_defined?(:VERSION)
21
+ VERSION = '0.5.0' unless Funicular.const_defined?(:VERSION)
22
22
 
23
23
  def self.version
24
24
  VERSION
@@ -60,6 +60,23 @@ module Funicular
60
60
  @router
61
61
  end
62
62
 
63
+ # Confirmation dialog used by the router's navigation guard. The
64
+ # default asks through window.confirm; tests (and apps wanting a
65
+ # custom dialog) can replace it with a proc taking the message and
66
+ # returning true to leave, false to stay.
67
+ @confirm_handler = nil
68
+
69
+ def self.confirm_handler=(handler)
70
+ @confirm_handler = handler
71
+ end
72
+
73
+ def self.confirm(message)
74
+ handler = @confirm_handler
75
+ return !!handler.call(message) if handler
76
+ return true if server?
77
+ !!JS.global.confirm(message)
78
+ end
79
+
63
80
  # Read the SSR state embedded by the server (funicular_state_tag) as a
64
81
  # Ruby Hash with string keys. Returns {} when absent or on the server.
65
82
  # Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
@@ -97,11 +114,17 @@ module Funicular
97
114
  child.is_a?(JS::Element) ? child : nil
98
115
  end
99
116
 
100
- # Load schemas for models
117
+ # The schema boot barrier (docs decisions 6/19).
101
118
  # Usage:
102
119
  # Funicular.load_schemas({ User => "user", Session => "session" }) do
103
120
  # Funicular.start(container: 'app') { |router| ... }
104
121
  # end
122
+ # EVERY request settles its slot exactly once -- success, HTTP
123
+ # error, or a schema that fails to apply -- so the barrier always
124
+ # completes. All green: an opted-in local database boots before the
125
+ # completion block; a REST-only app runs the block directly. Any failure:
126
+ # the block is NEVER invoked and the errors reach the console and
127
+ # config.on_boot_error. Only an active DB lifecycle is marked failed.
105
128
  def self.load_schemas(models, &block)
106
129
  # On the server there is no fetch and no need for client-side schemas:
107
130
  # SSR injects plain data into component state directly. Just run the
@@ -111,28 +134,116 @@ module Funicular
111
134
  return
112
135
  end
113
136
 
114
- schemas_loaded = 0
115
- total_schemas = models.size
116
-
117
- check_completion = -> {
118
- if schemas_loaded >= total_schemas
119
- puts "[Funicular] All schemas loaded (#{schemas_loaded}/#{total_schemas})"
120
- block.call if block
121
- end
137
+ # Arm the page's epoch before the first request leaves: schema
138
+ # responses are epoch-checked too (docs decision 13). The response
139
+ # gate latches lazily on its own; the explicit call keeps the
140
+ # whole barrier deterministically armed at issue time.
141
+ local_database = Funicular::DB.local_database_enabled?
142
+ Funicular::DB.__latch_page_epoch if local_database
143
+
144
+ total = models.size
145
+ settled = 0
146
+ # @type var errors: Array[untyped]
147
+ errors = []
148
+ completed = false
149
+
150
+ settle = -> {
151
+ settled += 1
152
+ # Exactly once, and only with every slot settled.
153
+ next if completed
154
+ next if settled < total
155
+ completed = true
156
+ __settle_boot_barrier(errors, &block)
122
157
  }
123
158
 
124
- models.each do |model_class, schema_name|
125
- HTTP.get("/api/schema/#{schema_name}") do |response|
126
- if response.error?
127
- puts "[Schema] Failed to load #{schema_name} schema: #{response.error_message}"
128
- else
159
+ if total == 0
160
+ __settle_boot_barrier(errors, &block)
161
+ return
162
+ end
163
+
164
+ entries = models.to_a
165
+ entries_size = entries.size
166
+ i = 0
167
+ while i < entries_size
168
+ entry = entries[i]
169
+ # One request per method call: the response block must capture
170
+ # ITS model and name, and a while loop's shared locals would all
171
+ # resolve to the last pair by response time.
172
+ __request_schema(entry[0], entry[1], errors, settle)
173
+ i += 1
174
+ end
175
+ end
176
+
177
+ def self.__request_schema(model_class, schema_name, errors, settle)
178
+ HTTP.get("/api/schema/#{schema_name}") do |response|
179
+ if response.error?
180
+ # Status and model always; the body's message only when the
181
+ # server actually sent one (an empty or HTML error body has
182
+ # no error_message).
183
+ message = "schema #{schema_name} (#{model_class.to_s}): " \
184
+ "HTTP #{response.status}"
185
+ detail = response.error_message
186
+ message = "#{message}: #{detail}" if detail
187
+ errors << Funicular::DB::Error.new(message)
188
+ else
189
+ begin
129
190
  model_class.load_schema(response.data)
130
191
  puts "[Schema] #{schema_name} model initialized"
131
- schemas_loaded += 1
132
- check_completion.call
192
+ rescue => e
193
+ # A schema that arrived but cannot be applied settles as a
194
+ # failure -- the barrier must never hang on it. Wrapped so
195
+ # on_boot_error can tell WHICH model broke among several.
196
+ errors << Funicular::DB::Error.new(
197
+ "schema #{schema_name} (#{model_class.to_s}): " \
198
+ "#{e.class}: #{e.message}")
199
+ end
200
+ end
201
+ settle.call
202
+ end
203
+ nil
204
+ end
205
+
206
+ # The barrier settled: boot on all-green (the completion block runs
207
+ # only when the boot itself succeeded too), fail loud otherwise.
208
+ def self.__settle_boot_barrier(errors, &block)
209
+ if errors.empty?
210
+ if Funicular::DB.local_database_enabled?
211
+ block.call if Funicular::DB.boot && block
212
+ else
213
+ block.call if block
214
+ end
215
+ else
216
+ if Funicular::DB.local_database_enabled?
217
+ Funicular::DB.__fail_boot(errors)
218
+ else
219
+ Funicular::DB.__report_boot_errors(errors)
220
+ end
221
+ end
222
+ nil
223
+ end
224
+
225
+ # Funicular.start's client-side gate (docs decision 19): apps with
226
+ # opted-in replica models boot inside the schema barrier above; opted-in
227
+ # local-only apps (no load_schemas call) boot right here. REST-only apps
228
+ # bypass DB boot, except that an explicit storage :local declaration fails.
229
+ def self.__boot_for_start
230
+ unless Funicular::DB.local_database_enabled?
231
+ models = Funicular::Model.__registered_models
232
+ i = 0
233
+ models_size = models.size
234
+ while i < models_size
235
+ if models[i].local?
236
+ raise Funicular::DB::ConfigError,
237
+ "storage :local requires config.local_database = true"
133
238
  end
239
+ i += 1
134
240
  end
241
+ return true
135
242
  end
243
+ state = Funicular::DB.boot_state
244
+ return true if state == :ready
245
+ return false unless state == :unbooted
246
+ Funicular::DB.boot
136
247
  end
137
248
 
138
249
  # Start Funicular application
@@ -153,6 +264,14 @@ module Funicular
153
264
  return nil
154
265
  end
155
266
 
267
+ # An opted-in local database comes up before anything mounts. A failed
268
+ # boot already reported itself, so start quietly refuses to mount on it;
269
+ # a REST-only application passes this gate without touching the DB.
270
+ unless __boot_for_start
271
+ puts "[Funicular] start aborted: the local database did not boot"
272
+ return nil
273
+ end
274
+
156
275
  # Export debug configuration to JavaScript
157
276
  export_debug_config
158
277
 
data/mrblib/http.rb CHANGED
@@ -1,13 +1,12 @@
1
1
  module Funicular
2
2
  module HTTP
3
- CACHE_DB_NAME = 'funicular_http_cache'.freeze
4
- CACHE_STORE = 'responses'.freeze
5
-
6
- @cache = nil
7
-
8
3
  class Response
9
4
  attr_reader :data, :status, :ok
10
5
 
6
+ # Every mainstream HTTP client calls the payload `body`; keep
7
+ # that name working alongside `data`.
8
+ alias body data
9
+
11
10
  def initialize(status, data)
12
11
  @status = status
13
12
  @ok = @status >= 200 && @status < 300
@@ -26,73 +25,23 @@ module Funicular
26
25
  end
27
26
  end
28
27
 
29
- # Open (or reuse) the response cache store. Idempotent and safe to call
30
- # multiple times. Falls back to the in-memory backing if browser
31
- # IndexedDB is unavailable.
32
- def self.cache_init!
33
- cache = @cache
34
- return cache if cache
35
- @cache = IndexedDB::KVS.open(CACHE_DB_NAME, store: CACHE_STORE)
36
- end
37
-
38
- # Drop a single cached entry by URL key. No-op if the cache is not
39
- # initialized.
40
- def self.cache_purge(url)
41
- cache = @cache
42
- return nil unless cache
43
- cache.delete(url)
44
- nil
45
- end
46
-
47
- # Drop every cached entry. No-op if the cache is not initialized.
48
- def self.cache_clear
49
- cache = @cache
50
- return nil unless cache
51
- cache.clear
52
- nil
53
- end
54
-
55
- # Internal: read the cache for *url*. Returns the parsed entry hash or
56
- # nil. Lazily initializes the cache on first use so callers can pass
57
- # `cache:` without booting the SPA shell first.
58
- def self.cache_lookup(url)
59
- cache_init! unless @cache
60
- cache = @cache
61
- return nil unless cache
62
- cache[url]
63
- end
64
-
65
- # Internal: write *entry* (a Hash with status/data/cached_at) to the
66
- # cache. Awaits one extra Promise so the next request reliably hits.
67
- def self.cache_write(url, entry)
68
- cache_init! unless @cache
69
- cache = @cache
70
- return nil unless cache
71
- cache[url] = entry
72
- nil
73
- end
74
-
75
- def self.get(url, cache: nil, &block)
76
- request("GET", url, nil, cache: cache, &block)
28
+ def self.get(url, &block)
29
+ request("GET", url, nil, &block)
77
30
  end
78
31
 
79
- def self.post(url, body = nil, cache: nil, &block)
80
- warn_unsupported_cache("post") if cache
32
+ def self.post(url, body = nil, &block)
81
33
  request("POST", url, body, &block)
82
34
  end
83
35
 
84
- def self.patch(url, body = nil, cache: nil, &block)
85
- warn_unsupported_cache("patch") if cache
36
+ def self.patch(url, body = nil, &block)
86
37
  request("PATCH", url, body, &block)
87
38
  end
88
39
 
89
- def self.delete(url, cache: nil, &block)
90
- warn_unsupported_cache("delete") if cache
40
+ def self.delete(url, &block)
91
41
  request("DELETE", url, nil, &block)
92
42
  end
93
43
 
94
- def self.put(url, body = nil, cache: nil, &block)
95
- warn_unsupported_cache("put") if cache
44
+ def self.put(url, body = nil, &block)
96
45
  request("PUT", url, body, &block)
97
46
  end
98
47
 
@@ -111,29 +60,6 @@ module Funicular
111
60
  class << self
112
61
  private
113
62
 
114
- def warn_unsupported_cache(verb)
115
- puts "[Funicular::HTTP] cache: option is GET-only; ignoring on #{verb.upcase}"
116
- end
117
-
118
- def now_seconds
119
- # JavaScript Date.now() returns ms since epoch
120
- ms = JS.global[:Date].now # steep:ignore
121
- (ms.to_i / 1000)
122
- end
123
-
124
- def cache_hit?(entry, ttl)
125
- return false unless entry.is_a?(Hash)
126
- cached_at = entry["cached_at"]
127
- return false unless cached_at.is_a?(Integer)
128
- (now_seconds - cached_at) <= ttl
129
- end
130
-
131
- def serve_from_cache(entry, &block)
132
- status = entry["status"].to_i
133
- data = entry["data"]
134
- block.call(Response.new(status, data)) if block
135
- end
136
-
137
63
  def parse_response_body(text)
138
64
  return nil if text.nil?
139
65
 
@@ -145,15 +71,17 @@ module Funicular
145
71
  body
146
72
  end
147
73
 
148
- def request(method, url, body, cache: nil, &block)
149
- if method == "GET" && cache.is_a?(Integer) && cache > 0
150
- entry = cache_lookup(url)
151
- if cache_hit?(entry, cache)
152
- serve_from_cache(entry, &block)
153
- return
154
- end
74
+ def request(method, url, body, &block)
75
+ # A terminal page must not TALK to the server either (docs
76
+ # decision 13): discarding the response is not enough, because
77
+ # the request itself would already have executed under the NEW
78
+ # session's cookies -- an old screen's click could mutate
79
+ # another user's data. Refused BEFORE the fetch; the callback
80
+ # still settles exactly once.
81
+ if Funicular::DB.session_terminated?
82
+ block.call(session_changed_response) if block
83
+ return nil
155
84
  end
156
-
157
85
  # @type var options: Hash[Symbol, String | Hash[String, String]]
158
86
  options = { method: method, credentials: "include" }
159
87
 
@@ -171,24 +99,73 @@ module Funicular
171
99
 
172
100
  options[:headers] = headers unless headers.empty?
173
101
 
174
- JS.global.fetch(url, options) do |response|
175
- status = response.status.to_i
176
- json_text = response.to_binary
177
- data = parse_response_body(json_text)
178
- # @type var status: Integer
179
- http_response = Response.new(status, data)
180
-
181
- if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
182
- cache_write(url, {
183
- "status" => status,
184
- "data" => data,
185
- "cached_at" => now_seconds
186
- })
102
+ settled = false
103
+ begin
104
+ JS.global.fetch(url, options) do |response|
105
+ # The epoch decides BEFORE the body is touched. fetch
106
+ # resolves once the headers arrive -- which is all this
107
+ # check needs -- but to_binary can still fail on an
108
+ # interrupted body stream, and the rescue below would then
109
+ # settle with a network error without ever processing the
110
+ # mismatch: the page would stay non-terminal and free to
111
+ # issue another request under the NEW session.
112
+ if Funicular::DB.__session_epoch_ok?(response_epoch(response))
113
+ # @type var status: Integer
114
+ status = response.status.to_i
115
+ json_text = response.to_binary
116
+ data = parse_response_body(json_text)
117
+ http_response = Response.new(status, data)
118
+ else
119
+ # The session changed under this page (docs decision 13):
120
+ # the response is DISCARDED, and the caller settles with
121
+ # an error instead of applying stale-session data.
122
+ http_response = session_changed_response
123
+ end
124
+ settled = true
125
+ block.call(http_response) if block
126
+ end
127
+ rescue => e
128
+ # Exactly-once settle: a rejected fetch (network failure,
129
+ # invalid URL) must still deliver a response -- a hanging
130
+ # callback would hang the schema barrier and every REST
131
+ # caller. An exception out of the caller's OWN block must
132
+ # NOT settle a second time. It is re-raised into the JS
133
+ # bridge, where it can vanish silently, so name the culprit
134
+ # on the console first: a swallowed typo in a response
135
+ # handler otherwise just freezes the page in its loading
136
+ # state.
137
+ if settled
138
+ puts "[Funicular::HTTP] #{method} #{url} callback raised " \
139
+ "#{e.class}: #{e.message}"
140
+ raise e
141
+ end
142
+ settled = true
143
+ if block
144
+ block.call(Response.new(0,
145
+ { "error" => "network error: #{e.class}: #{e.message}" }))
187
146
  end
188
-
189
- block.call(http_response) if block
190
147
  end
191
148
  end
149
+
150
+ def session_changed_response
151
+ Response.new(0,
152
+ { "error" => "the session changed; this page is " \
153
+ "terminal (reload to continue)" })
154
+ end
155
+
156
+ # The X-Funicular-Epoch response header, nil when absent (no
157
+ # headers surface, no such header, or a null value through the
158
+ # JS bridge).
159
+ def response_epoch(response)
160
+ # @type var raw: untyped
161
+ raw = response
162
+ value = raw[:headers].get("X-Funicular-Epoch").to_s
163
+ return nil if value.empty?
164
+ return nil if value == "null" || value == "undefined"
165
+ value
166
+ rescue
167
+ nil
168
+ end
192
169
  end
193
170
  end
194
171
  end