funicular 0.0.1 → 0.2.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
data/mrblib/router.rb ADDED
@@ -0,0 +1,266 @@
1
+ module Funicular
2
+ class Router
3
+ attr_reader :routes, :current_component, :current_path
4
+
5
+ def initialize(container)
6
+ @container = container
7
+ @routes = []
8
+ @default_route = nil
9
+ @current_component = nil
10
+ @current_path = nil
11
+ @popstate_callback_id = nil
12
+ @url_helpers = Module.new
13
+ Funicular.const_set(:RouteHelpers, @url_helpers) unless Funicular.const_defined?(:RouteHelpers)
14
+ end
15
+
16
+ # Rails-style DSL methods
17
+ def get(path, to:, as: nil, constraints: nil)
18
+ add_route_with_method(:get, path, to, as, constraints)
19
+ end
20
+
21
+ def post(path, to:, as: nil, constraints: nil)
22
+ add_route_with_method(:post, path, to, as, constraints)
23
+ end
24
+
25
+ def put(path, to:, as: nil, constraints: nil)
26
+ add_route_with_method(:put, path, to, as, constraints)
27
+ end
28
+
29
+ def patch(path, to:, as: nil, constraints: nil)
30
+ add_route_with_method(:patch, path, to, as, constraints)
31
+ end
32
+
33
+ def delete(path, to:, as: nil, constraints: nil)
34
+ add_route_with_method(:delete, path, to, as, constraints)
35
+ end
36
+
37
+ # Add a route (backward compatibility)
38
+ def add_route(path, component_class, as: nil, constraints: nil)
39
+ add_route_with_method(:get, path, component_class, as, constraints)
40
+ end
41
+
42
+ # Set default route (used when path is empty)
43
+ def set_default(path)
44
+ @default_route = path
45
+ end
46
+
47
+ # Resolve a path to [component_class, params] without any DOM/JS work.
48
+ # Public entry point used by server-side rendering.
49
+ def match(path)
50
+ find_route(path)
51
+ end
52
+
53
+ # Start listening to popstate
54
+ #
55
+ # When hydrate is true and the container already holds server-rendered
56
+ # markup, the initial route hydrates that DOM instead of mounting fresh.
57
+ def start(hydrate: false)
58
+ # No browser history on the server.
59
+ return if Funicular.server?
60
+
61
+ @hydrate_initial = hydrate
62
+
63
+ # Clean up existing listener if any (prevents duplicate registration)
64
+ if @popstate_callback_id
65
+ JS::Object.removeEventListener(@popstate_callback_id)
66
+ @popstate_callback_id = nil
67
+ end
68
+
69
+ # Set up popstate listener
70
+ @popstate_callback_id = JS.global.addEventListener('popstate') do |event|
71
+ handle_route_change
72
+ end
73
+
74
+ # Handle initial route. Skip the default-route redirect when hydrating
75
+ # server content: the server already rendered for the current path.
76
+ hydrating_now = @hydrate_initial && Funicular.first_element_child(@container)
77
+ if !hydrating_now && current_location_path == '/' && @default_route
78
+ # Use replaceState to not add a new entry to the history
79
+ JS.global.history.replaceState(JS::Bridge.to_js({}), '', @default_route)
80
+ end
81
+ handle_route_change
82
+ end
83
+
84
+ # Stop listening to popstate
85
+ def stop
86
+ if @popstate_callback_id
87
+ JS::Object.removeEventListener(@popstate_callback_id)
88
+ @popstate_callback_id = nil
89
+ end
90
+
91
+ unmount_current_component
92
+ end
93
+
94
+ # Navigate to a path programmatically using History API
95
+ def navigate(path)
96
+ JS.global.history.pushState(JS::Bridge.to_js({}), '', path)
97
+ # Manually trigger route change because pushState doesn't fire popstate
98
+ handle_route_change
99
+ end
100
+
101
+ # Get current path from location
102
+ def current_location_path
103
+ js_path_obj = JS.global.location.pathname
104
+ path = js_path_obj.to_s
105
+ path.empty? ? '/' : path
106
+ end
107
+
108
+ private
109
+
110
+ # Handle route change
111
+ def handle_route_change
112
+ path = current_location_path
113
+
114
+ # Hydration only applies to the very first navigation. Consume the flag
115
+ # here so an unmatched initial route does not leave it set for later.
116
+ hydrate_now = @hydrate_initial
117
+ @hydrate_initial = false
118
+
119
+ # Find matching route
120
+ component_class, params = find_route(path)
121
+
122
+ unless component_class
123
+ # Maybe render a 404 component?
124
+ return
125
+ end
126
+
127
+ # Don't remount if already on this path
128
+ return if @current_path == path
129
+
130
+ # Unmount current component
131
+ unmount_current_component
132
+
133
+ # Mount new component
134
+ @current_path = path
135
+ @current_component = component_class.new(params)
136
+ # @type ivar @current_component: Funicular::Component
137
+
138
+ server_root = hydrate_now ? Funicular.first_element_child(@container) : nil
139
+
140
+ if server_root
141
+ begin
142
+ @current_component.seed_state(Funicular.window_state)
143
+ @current_component.hydrate(server_root)
144
+ return
145
+ rescue => e
146
+ # Server/client disagreed: discard server DOM and render fresh.
147
+ puts "[Funicular] Hydration failed, falling back to full render: #{e.message}"
148
+ @container[:innerHTML] = ''
149
+ @current_component = component_class.new(params)
150
+ end
151
+ end
152
+
153
+ @current_component.mount(@container)
154
+ end
155
+
156
+ # Unmount current component
157
+ def unmount_current_component
158
+ @current_component&.unmount
159
+ @current_component = nil
160
+ @current_path = nil
161
+ end
162
+
163
+ private
164
+
165
+ def add_route_with_method(method, path, component_class, name = '', constraints = nil)
166
+ pattern_segments = path.split('/').reject { |s| s.empty? }
167
+ route = {
168
+ method: method,
169
+ path: path,
170
+ component: component_class,
171
+ name: name,
172
+ pattern_segments: pattern_segments,
173
+ constraints: constraints || {}
174
+ }
175
+ # @type var route: Funicular::route_definition_t
176
+ @routes << route
177
+
178
+ # Generate URL helper if name is provided
179
+ generate_url_helper(name, path) if name
180
+ end
181
+
182
+ def generate_url_helper(name, path_pattern)
183
+ helper_method_name = "#{name}_path".to_sym
184
+
185
+ # Check for duplicate helper names
186
+ if @url_helpers.instance_methods.include?(helper_method_name)
187
+ raise "URL helper '#{helper_method_name}' is already defined"
188
+ end
189
+
190
+ # Extract parameter names from path pattern (without regex)
191
+ param_names = extract_param_names(path_pattern)
192
+
193
+ # Define the helper method
194
+ if param_names.empty?
195
+ # No parameters - return static path
196
+ @url_helpers.module_eval do
197
+ define_method(helper_method_name) do # steep:ignore
198
+ path_pattern
199
+ end
200
+ end
201
+ else
202
+ # With parameters
203
+ @url_helpers.module_eval do
204
+ define_method(helper_method_name) do |*args| # steep:ignore
205
+ # Handle model objects with id method
206
+ if args.length == 1 && args[0].respond_to?(:id) && param_names.length == 1
207
+ args = [args[0].id]
208
+ elsif args.length != param_names.length
209
+ raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
210
+ end
211
+
212
+ result = path_pattern.dup
213
+ param_names.each_with_index do |param, idx|
214
+ result = result.sub(":#{param}", args[idx].to_s)
215
+ end
216
+ result
217
+ end
218
+ end
219
+ end
220
+ end
221
+
222
+ def extract_param_names(path_pattern)
223
+ path_pattern.split('/').select { |s|
224
+ s.start_with?(':')
225
+ }.map {
226
+ |s| s[1..-1]&.to_sym
227
+ }.compact
228
+ end
229
+
230
+ def find_route(path)
231
+ path_segments = path.split('/').reject { |s| s.empty? }
232
+ params = {} #: Hash[Symbol, untyped]
233
+
234
+ @routes.each do |route|
235
+ pattern_segments = route[:pattern_segments]
236
+ next if pattern_segments.length != path_segments.length
237
+
238
+ match = true
239
+
240
+ pattern_segments.each_with_index do |pattern_segment, index|
241
+ path_segment = path_segments[index]
242
+
243
+ if pattern_segment.start_with?(':')
244
+ param_name = pattern_segment[1..-1]&.to_sym
245
+ if param_name.nil?
246
+ raise "Invalid parameter name in route pattern: #{route[:path]}"
247
+ end
248
+ constraint = route[:constraints][param_name]
249
+ if constraint && !constraint.match?(path_segment)
250
+ match = false
251
+ break
252
+ end
253
+ params[param_name] = path_segment
254
+ elsif pattern_segment != path_segment
255
+ match = false
256
+ break
257
+ end
258
+ end
259
+
260
+ return [route[:component], params] if match
261
+ end
262
+
263
+ [nil, params] # No route found
264
+ end
265
+ end
266
+ end
data/mrblib/store.rb ADDED
@@ -0,0 +1,304 @@
1
+ module Funicular
2
+ # Declarative client-side store backed by IndexedDB::KVS.
3
+ #
4
+ # Subclass either Funicular::Store::Singleton (one value per scope) or
5
+ # Funicular::Store::Collection (ordered list per scope) and use the
6
+ # class-level DSL to wire everything up. See store_singleton.rb /
7
+ # store_collection.rb for the user-facing API.
8
+ class Store
9
+ # event_name => Array of store classes that subscribed via cleared_on
10
+ EVENT_REGISTRY = {} #: Hash[Symbol, Array[singleton(Funicular::Store)]]
11
+
12
+ # [database, kvs_store] => IndexedDB::KVS instance, shared across store classes
13
+ KVS_POOL = {} #: Hash[Array[String], IndexedDB::KVS]
14
+
15
+ # Snapshot of a `subscribes_to` declaration captured on the store class.
16
+ SubscribesTo = Data.define(:channel_name, :params_proc, :handler_block)
17
+
18
+ # Thin wrapper around a Funicular::Cable::Subscription. Exists so the
19
+ # store layer can hold lifecycle ownership without leaking the cable
20
+ # type into user code.
21
+ class Subscription
22
+ attr_reader :cable_sub
23
+
24
+ def initialize(cable_sub)
25
+ @cable_sub = cable_sub
26
+ end
27
+
28
+ def unsubscribe
29
+ @cable_sub.unsubscribe
30
+ nil
31
+ end
32
+ end
33
+
34
+ # Per-scope accessor returned by Funicular::Store.where(...). Singleton
35
+ # and Collection define their own subclasses with the data-shape API.
36
+ class Scope
37
+ attr_reader :store_class, :scope_kwargs
38
+
39
+ def initialize(store_class, scope_kwargs)
40
+ @store_class = store_class
41
+ @scope_kwargs = scope_kwargs
42
+ @on_change = {}
43
+ @next_cb_id = 0
44
+ @subscription = nil
45
+ end
46
+
47
+ # Expose scope kwargs as method calls (e.g. scope.channel_id) so that
48
+ # `params: ->(s) { { channel_id: s.channel_id } }` reads naturally in
49
+ # the subscribes_to DSL.
50
+ def method_missing(name, *args)
51
+ if @scope_kwargs.key?(name)
52
+ @scope_kwargs[name]
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def respond_to_missing?(name, include_private = false)
59
+ @scope_kwargs.key?(name) || super
60
+ end
61
+
62
+ def on_change(&blk)
63
+ @next_cb_id += 1
64
+ id = @next_cb_id
65
+ @on_change[id] = blk
66
+ id
67
+ end
68
+
69
+ def off_change(id)
70
+ @on_change.delete(id)
71
+ nil
72
+ end
73
+
74
+ def subscribed?
75
+ !@subscription.nil?
76
+ end
77
+
78
+ def subscription
79
+ @subscription
80
+ end
81
+
82
+ # Lazily create a Cable subscription for this scope using the store
83
+ # class's `subscribes_to` declaration. The handler block runs with
84
+ # `self == this Scope`, so bareword calls like `replace`, `append`,
85
+ # and `remove` resolve to the scope's mutators.
86
+ def subscribe!
87
+ existing = @subscription
88
+ return existing if existing
89
+ cable_binding = @store_class.__cable_binding
90
+ raise "no subscribes_to declared on #{@store_class}" unless cable_binding
91
+ consumer = @store_class.__consumer
92
+ params = cable_binding.params_proc.call(self)
93
+ handler = cable_binding.handler_block
94
+ scope = self
95
+ cable_sub = consumer.subscriptions.create(params) do |data|
96
+ scope.instance_exec(data, scope, &handler) # steep:ignore
97
+ end
98
+ sub = Funicular::Store::Subscription.new(cable_sub)
99
+ @subscription = sub
100
+ sub
101
+ end
102
+
103
+ def unsubscribe!
104
+ sub = @subscription
105
+ return nil unless sub
106
+ sub.unsubscribe
107
+ @subscription = nil
108
+ end
109
+
110
+ private
111
+
112
+ def storage_key
113
+ parts = [] #: Array[String]
114
+ @scope_kwargs.each do |k, v|
115
+ parts << "#{k}=#{v}"
116
+ end
117
+ parts.join(":")
118
+ end
119
+
120
+ def kvs
121
+ @store_class.__kvs
122
+ end
123
+
124
+ def now_seconds
125
+ Time.now.to_i
126
+ end
127
+
128
+ def expired_record?(rec)
129
+ return false unless rec.is_a?(Hash)
130
+ ttl = rec["expires_in"]
131
+ return false unless ttl.is_a?(Integer) && 0 < ttl
132
+ wrote = rec["wrote_at"]
133
+ return false unless wrote.is_a?(Integer)
134
+ ttl < (now_seconds - wrote)
135
+ end
136
+
137
+ def fire_change(snapshot)
138
+ @on_change.each_value do |cb|
139
+ begin
140
+ cb.call(snapshot)
141
+ rescue => e
142
+ puts "[Funicular::Store] on_change error in #{@store_class}: #{e.class}: #{e.message}"
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # ------------------------------------------------------------------
149
+ # Class-level DSL & runtime
150
+ # ------------------------------------------------------------------
151
+
152
+ class << self
153
+ attr_reader :__database, :__kvs_store_name, :__scope_keys,
154
+ :__expires_in, :__source, :__belongs_to,
155
+ :__cable_url, :__cable_binding,
156
+ :__cleared_handlers
157
+
158
+ # IndexedDB database name. Required (no implicit default; users
159
+ # should pin database names so refactors stay data-compatible).
160
+ def database(name)
161
+ @__database = name.to_s
162
+ end
163
+
164
+ # Object-store name within the database. Default: "kv".
165
+ def kvs_store(name)
166
+ @__kvs_store_name = name.to_s
167
+ end
168
+
169
+ # Declare scope keys. Single Symbol or splat of Symbols.
170
+ #
171
+ # scope :channel_id
172
+ # scope :channel_id, :user_id
173
+ def scope(*keys)
174
+ @__scope_keys = keys.map { |k| k.to_sym }
175
+ end
176
+
177
+ def expires_in(seconds)
178
+ @__expires_in = seconds
179
+ end
180
+
181
+ # Declarative-only annotation pointing at a Funicular::Model class.
182
+ # Has no behaviour; intended for documentation / tooling.
183
+ def source(model_class)
184
+ @__source = model_class
185
+ end
186
+
187
+ # Declarative-only association marker (e.g. `belongs_to :channel`).
188
+ # No behaviour.
189
+ def belongs_to(name)
190
+ @__belongs_to = name.to_sym
191
+ end
192
+
193
+ def cable_url(url)
194
+ @__cable_url = url.to_s
195
+ end
196
+
197
+ # Capture a Cable subscription binding. The block is invoked via
198
+ # instance_exec on the Scope when the cable subscription receives a
199
+ # message, so bareword `replace` / `append` / `remove` resolve to the
200
+ # scope's own mutators.
201
+ def subscribes_to(channel_name, params:, &block)
202
+ raise "subscribes_to requires a block" unless block
203
+ @__cable_binding = SubscribesTo.new(channel_name.to_s, params, block)
204
+ end
205
+
206
+ # Register this store class for one or more global event names. When
207
+ # Funicular::Store.dispatch(:event) is called, every registered class
208
+ # has its data wiped (default) or runs the user-supplied block.
209
+ def cleared_on(*event_names, &block)
210
+ pool = (@__cleared_handlers ||= {}) #: Hash[Symbol, Proc?]
211
+ registry = Funicular::Store::EVENT_REGISTRY
212
+ event_names.each do |ev|
213
+ sym = ev.to_sym
214
+ pool[sym] = block
215
+ arr = (registry[sym] ||= []) #: Array[singleton(Funicular::Store)]
216
+ arr << self unless arr.include?(self)
217
+ end
218
+ end
219
+
220
+ # Return (and memoize) the Scope for the given scope kwargs. Same
221
+ # kwargs always return the same Scope instance, which is required so
222
+ # `on_change` callbacks attach to a single identity.
223
+ def where(**scope_kwargs)
224
+ validate_scope_kwargs!(scope_kwargs)
225
+ pool = (@__scope_pool ||= {}) #: Hash[Hash[Symbol, untyped], Funicular::Store::Scope]
226
+ existing = pool[scope_kwargs]
227
+ return existing if existing
228
+ scope = scope_class.new(self, scope_kwargs)
229
+ pool[scope_kwargs] = scope
230
+ scope
231
+ end
232
+
233
+ # Subclasses (Singleton / Collection) override to return the
234
+ # appropriate Scope class.
235
+ def scope_class
236
+ raise NotImplementedError, "#{self} must subclass Funicular::Store::Singleton or Funicular::Store::Collection"
237
+ end
238
+
239
+ def __kvs
240
+ db = @__database || raise("#{self}: missing `database \"...\"` declaration")
241
+ store_name = @__kvs_store_name || "kv"
242
+ key = [db, store_name]
243
+ existing = Funicular::Store::KVS_POOL[key]
244
+ return existing if existing
245
+ kvs = IndexedDB::KVS.open(db, store: store_name)
246
+ Funicular::Store::KVS_POOL[key] = kvs
247
+ kvs
248
+ end
249
+
250
+ def __consumer
251
+ @__consumer ||= Funicular::Cable.create_consumer(@__cable_url || "/cable")
252
+ end
253
+
254
+ def __handle_dispatch(event, payload)
255
+ pool = @__cleared_handlers
256
+ block = pool ? pool[event] : nil
257
+ if block
258
+ instance_exec(payload) { |p| block.call(p) }
259
+ else
260
+ __clear_all!
261
+ end
262
+ nil
263
+ end
264
+
265
+ def __clear_all!
266
+ return nil unless @__database
267
+ __kvs.clear
268
+ @__scope_pool = {} if @__scope_pool
269
+ nil
270
+ end
271
+
272
+ private
273
+
274
+ def validate_scope_kwargs!(scope_kwargs)
275
+ declared = @__scope_keys || []
276
+ given = scope_kwargs.keys
277
+ unknown = given - declared
278
+ unless unknown.empty?
279
+ raise ArgumentError, "#{self}: unknown scope keys #{unknown.inspect}; declared #{declared.inspect}"
280
+ end
281
+ missing = declared - given
282
+ unless missing.empty?
283
+ raise ArgumentError, "#{self}: missing scope keys #{missing.inspect}"
284
+ end
285
+ end
286
+ end
287
+
288
+ # Dispatch an event to every store class that registered via
289
+ # `cleared_on`. Default per-class action is `__clear_all!` (wipe the
290
+ # KVS + drop memoized scopes); override per class with a block.
291
+ def self.dispatch(event, payload = nil)
292
+ sym = event.to_sym
293
+ classes = Funicular::Store::EVENT_REGISTRY[sym] || []
294
+ classes.each do |klass|
295
+ begin
296
+ klass.__handle_dispatch(sym, payload)
297
+ rescue => e
298
+ puts "[Funicular::Store] dispatch(#{sym.inspect}) error in #{klass}: #{e.class}: #{e.message}"
299
+ end
300
+ end
301
+ nil
302
+ end
303
+ end
304
+ end