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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- 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
|