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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+
8
+ # Covers the freshness decision behind Funicular::Testing.ensure_compiled!.
9
+ # The actual compile (Node + vendored mrbc) is integration-only, matching
10
+ # the compiler tests' scope.
11
+ class TestingEnsureCompiledTest < Minitest::Test
12
+ def test_stale_when_bundle_is_missing
13
+ Dir.mktmpdir do |dir|
14
+ assert Funicular::Testing.stale?(File.join(dir, "app.mrb"), [])
15
+ end
16
+ end
17
+
18
+ def test_fresh_when_bundle_is_newer_than_every_source
19
+ Dir.mktmpdir do |dir|
20
+ source = File.join(dir, "component.rb")
21
+ output = File.join(dir, "app.mrb")
22
+ File.write(source, "")
23
+ File.write(output, "")
24
+ File.utime(Time.now - 60, Time.now - 60, source)
25
+
26
+ refute Funicular::Testing.stale?(output, [ source ])
27
+ end
28
+ end
29
+
30
+ def test_stale_when_any_source_is_newer_than_the_bundle
31
+ Dir.mktmpdir do |dir|
32
+ old_source = File.join(dir, "old.rb")
33
+ new_source = File.join(dir, "new.rb")
34
+ output = File.join(dir, "app.mrb")
35
+ [ old_source, new_source, output ].each { |f| File.write(f, "") }
36
+ File.utime(Time.now - 60, Time.now - 60, old_source)
37
+ File.utime(Time.now - 30, Time.now - 30, output)
38
+ File.utime(Time.now, Time.now, new_source)
39
+
40
+ assert Funicular::Testing.stale?(output, [ old_source, new_source ])
41
+ end
42
+ end
43
+
44
+ def test_missing_sources_do_not_count
45
+ Dir.mktmpdir do |dir|
46
+ output = File.join(dir, "app.mrb")
47
+ File.write(output, "")
48
+
49
+ refute Funicular::Testing.stale?(output, [ File.join(dir, "gone.rb") ])
50
+ end
51
+ end
52
+ end
@@ -30,6 +30,35 @@ class ValidationsTest < Minitest::Test
30
30
  assert_equal true, k.new("name" => "Alice").valid?
31
31
  end
32
32
 
33
+ def test_uncompilable_format_regex_is_skipped_not_fatal
34
+ k = model_class
35
+ out, _err = capture_io do
36
+ k.register_schema_validations(
37
+ "name" => {
38
+ "format" => { "with" => "[", "flags" => "" },
39
+ "presence" => true
40
+ }
41
+ )
42
+ end
43
+ # The warning names the rejected pattern so it can be found among
44
+ # many schemas loading at boot.
45
+ assert_match(/Skipping format validator/, out)
46
+ assert_match(/pattern: \[/, out)
47
+ # The broken format validator is dropped; the rest still applies and
48
+ # schema registration does not raise.
49
+ blank = k.new("name" => "")
50
+ assert_equal false, blank.valid?
51
+ assert_equal true, k.new("name" => "Alice").valid?
52
+ end
53
+
54
+ def test_schema_numericality_with_allow_nil_skips_nil
55
+ k = model_class({ "age" => false })
56
+ k.register_schema_validations("age" => { "numericality" => { "allow_nil" => true } })
57
+
58
+ assert_equal true, k.new("age" => nil).valid?
59
+ assert_equal false, k.new("age" => "abc").valid?
60
+ end
61
+
33
62
  def test_length_maximum_and_minimum
34
63
  k = model_class
35
64
  k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
@@ -170,14 +199,15 @@ class ValidationsTest < Minitest::Test
170
199
  m = k.new("name" => "ok")
171
200
  m.instance_variable_set("@id", 1)
172
201
 
173
- got_success = :unset
174
202
  got_result = :unset
175
- m.update("name" => "") do |success, result|
176
- got_success = success
203
+ got_error = :unset
204
+ m.update("name" => "") do |result, error|
177
205
  got_result = result
206
+ got_error = error
178
207
  end
179
208
 
180
- assert_equal false, got_success
181
- assert_equal ["can't be blank"], got_result[:name]
209
+ # Unified 0.5.0 callback contract: (result, error), result nil on failure
210
+ assert_nil got_result
211
+ assert_equal ["can't be blank"], got_error[:name]
182
212
  end
183
213
  end
data/mrbgem.rake CHANGED
@@ -7,7 +7,9 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
7
7
  spec.add_dependency 'picoruby-wasm'
8
8
  end
9
9
  spec.add_dependency 'picoruby-indexeddb'
10
+ spec.add_dependency 'picoruby-sqlite3'
10
11
  spec.add_dependency 'picoruby-json'
12
+ spec.add_dependency 'picoruby-uri'
11
13
  spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
12
14
  spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
13
15
  spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
data/mrblib/cable.rb CHANGED
@@ -229,7 +229,7 @@ module Funicular
229
229
 
230
230
  # Setup beforeunload handler to persist pending commands
231
231
  def setup_beforeunload_handler
232
- @beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
232
+ @beforeunload_callback_id = JS.global.addEventListener("beforeunload", sync: true) do
233
233
  save_pending_to_storage
234
234
  end
235
235
  end
data/mrblib/component.rb CHANGED
@@ -171,6 +171,72 @@ module Funicular
171
171
  self
172
172
  end
173
173
 
174
+ # Bind a state key to a local-database Relation (docs decision 10):
175
+ # the block runs once now, and again after every change event on the
176
+ # relation's table. Each evaluation re-subscribes (a branchy block
177
+ # may return a different model's relation this time) and patches
178
+ # state[key], triggering a re-render. Subscriptions die with the
179
+ # component (see unmount), even when a lifecycle hook raises.
180
+ def watch(key, &block)
181
+ unless block
182
+ raise ArgumentError, "watch requires a block returning a Relation"
183
+ end
184
+ evaluate_watch(key, block)
185
+ nil
186
+ end
187
+
188
+ # Framework use (the subscription re-enters here on every event).
189
+ def evaluate_watch(key, block)
190
+ relation = block.call
191
+ unless relation.is_a?(Funicular::Relation)
192
+ raise ArgumentError,
193
+ "the watch(:#{key}) block must return a Funicular::Relation, " \
194
+ "got #{relation.class}; for hashes, counts, or raw SQL, " \
195
+ "subscribe with Model.on_change and patch state yourself"
196
+ end
197
+ # Materialize BEFORE touching subscriptions: if the query raises,
198
+ # neither a fresh subscription leaks (initial watch) nor the
199
+ # previous healthy one is lost (re-evaluation). Next-tick delivery
200
+ # guarantees no change event interleaves with this evaluation.
201
+ records = relation.to_a
202
+ source = relation.__event_source
203
+ watches = (@__watches ||= {}) # steep:ignore UnannotatedEmptyCollection
204
+ old = watches[key]
205
+ Funicular::DB.unsubscribe(old) if old
206
+ component = self
207
+ watches[key] = Funicular::DB.subscribe(source[0], source[1]) do |r, t|
208
+ component.evaluate_watch(key, block)
209
+ end
210
+ if @mounted
211
+ # The normal update contract: patch runs the update lifecycle
212
+ # (component_will_update/component_updated/component_raised),
213
+ # normalizes values, and re-renders. The bus's next-tick
214
+ # delivery guarantees we are never inside another update here.
215
+ patch(key => records)
216
+ else
217
+ # Before mount there is no update lifecycle to honor yet: land
218
+ # the initial data directly so state is ready for the first
219
+ # render.
220
+ @state = @state.merge({ key => records })
221
+ @state_accessor = nil
222
+ end
223
+ nil
224
+ end
225
+
226
+ def cleanup_watches
227
+ watches = @__watches
228
+ return nil unless watches
229
+ keys = watches.keys
230
+ keys_size = keys.size
231
+ i = 0
232
+ while i < keys_size
233
+ Funicular::DB.unsubscribe(watches[keys[i]])
234
+ i += 1
235
+ end
236
+ @__watches = {}
237
+ nil
238
+ end
239
+
174
240
  # Load all registered suspense data
175
241
  # Called automatically in component_mounted if suspense definitions exist
176
242
  def load_suspense_data
@@ -408,6 +474,10 @@ module Funicular
408
474
 
409
475
  component_mounted if respond_to?(:component_mounted)
410
476
  rescue => e
477
+ # A failed mount never reaches unmount (@mounted is still
478
+ # false), so watch subscriptions taken before/during the mount
479
+ # must be released here or they would pin the dead component.
480
+ cleanup_watches
411
481
  component_raised(e) if respond_to?(:component_raised)
412
482
  raise e
413
483
  end
@@ -423,9 +493,12 @@ module Funicular
423
493
  # window.__FUNICULAR_STATE__ before calling this.
424
494
  def hydrate(dom_element)
425
495
  return if @mounted
426
- raise "hydrate: missing server DOM element" unless dom_element
427
496
 
428
497
  begin
498
+ # Inside the begin so a nil element takes the same cleanup path
499
+ # (releasing watch subscriptions) as every other hydrate failure.
500
+ raise "hydrate: missing server DOM element" unless dom_element
501
+
429
502
  component_will_mount if respond_to?(:component_will_mount)
430
503
 
431
504
  # The router/start sets the container; without it, unmount could not
@@ -467,11 +540,30 @@ module Funicular
467
540
 
468
541
  component_mounted if respond_to?(:component_mounted)
469
542
  rescue => e
543
+ # Same as mount: a failed hydrate cannot be unmounted, so the
544
+ # watch subscriptions must not outlive it.
545
+ cleanup_watches
470
546
  component_raised(e) if respond_to?(:component_raised)
471
547
  raise e
472
548
  end
473
549
  end
474
550
 
551
+ # Navigation guard: return a String message to ask the user for
552
+ # confirmation before this component is navigated away from (router
553
+ # navigation, browser back/forward, reload, tab close), or nil to
554
+ # allow leaving freely. Consulted by the router and its beforeunload
555
+ # listener; override in components with unsaved work:
556
+ #
557
+ # def navigation_guard
558
+ # state[:dirty] ? "Unsaved changes will be lost. Leave?" : nil
559
+ # end
560
+ #
561
+ # Must not suspend (no fetch/HTTP): the beforeunload path runs it on
562
+ # the synchronous JS event dispatch stack.
563
+ def navigation_guard
564
+ nil
565
+ end
566
+
475
567
  # Unmount component from DOM
476
568
  def unmount
477
569
  return unless @mounted
@@ -502,6 +594,11 @@ module Funicular
502
594
  rescue => e
503
595
  component_raised(e) if respond_to?(:component_raised)
504
596
  raise e
597
+ ensure
598
+ # Watch subscriptions die with the component NO MATTER WHAT: a
599
+ # raising lifecycle hook must not leave a zombie watcher
600
+ # patching an unmounted component (docs decision 10).
601
+ cleanup_watches
505
602
  end
506
603
  end
507
604
 
@@ -559,6 +656,7 @@ module Funicular
559
656
  begin
560
657
  self.send(value, event)
561
658
  rescue => e
659
+ report_handler_error(event_name, "##{value}", e)
562
660
  component_raised(e) if respond_to?(:component_raised)
563
661
  raise e
564
662
  end
@@ -575,6 +673,7 @@ module Funicular
575
673
  value.call(event)
576
674
  end
577
675
  rescue => e
676
+ report_handler_error(event_name, "##{value.name}", e)
578
677
  component_raised(e) if respond_to?(:component_raised)
579
678
  raise e
580
679
  end
@@ -591,6 +690,7 @@ module Funicular
591
690
  value.call(event)
592
691
  end
593
692
  rescue => e
693
+ report_handler_error(event_name, "(proc)", e)
594
694
  component_raised(e) if respond_to?(:component_raised)
595
695
  raise e
596
696
  end
@@ -623,6 +723,18 @@ module Funicular
623
723
  end
624
724
  end
625
725
 
726
+ # Name the culprit before an event handler error is re-raised into
727
+ # the JS bridge, where "Callback <id>: ArgumentError: ..." carries no
728
+ # hint of which component or handler it came from. Uses puts so the
729
+ # message reaches the browser console (same idiom as the
730
+ # ErrorBoundary logger). Never raises itself.
731
+ def report_handler_error(event_name, handler, error)
732
+ puts "[Funicular] #{self.class}#{handler} (on#{event_name}) raised " \
733
+ "#{error.class}: #{error.message}"
734
+ rescue
735
+ nil
736
+ end
737
+
626
738
  # Collect ref elements from VDOM
627
739
  # Called by VDOM::Renderer and Patcher
628
740
  def collect_refs(dom_element, vnode, refs_map = {})