funicular 0.1.0 → 0.2.1
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 +24 -0
- data/README.md +12 -4
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +25 -18
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -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/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- 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 +6908 -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 +3 -0
- 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 +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/form_for_test.rb +106 -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/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +246 -47
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +28 -6
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +78 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/vdom.rb +2 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +48 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/mrblib/patcher.rb
CHANGED
|
@@ -20,7 +20,9 @@ module Funicular
|
|
|
20
20
|
result = new_element
|
|
21
21
|
end
|
|
22
22
|
when :props
|
|
23
|
-
|
|
23
|
+
# Props patches only make sense for Element nodes (text nodes have
|
|
24
|
+
# no attributes); narrow before delegating.
|
|
25
|
+
update_props(element, patch[1]) if element.is_a?(JS::Element)
|
|
24
26
|
when :update_and_rebind
|
|
25
27
|
instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
|
|
26
28
|
old_dom_element = instance.dom_element
|
|
@@ -29,7 +31,7 @@ module Funicular
|
|
|
29
31
|
new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
|
|
30
32
|
|
|
31
33
|
# Update the instance's reference to its root DOM element if it changed
|
|
32
|
-
if new_dom_element != old_dom_element
|
|
34
|
+
if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
|
|
33
35
|
instance.dom_element = new_dom_element
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -37,11 +39,13 @@ module Funicular
|
|
|
37
39
|
instance.vdom = new_vdom
|
|
38
40
|
|
|
39
41
|
# Re-collect refs using the new DOM element
|
|
40
|
-
|
|
42
|
+
if new_dom_element.is_a?(JS::Element)
|
|
43
|
+
instance.collect_refs(new_dom_element, new_vdom)
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
# Re-bind events using the new DOM element
|
|
46
|
+
instance.cleanup_events
|
|
47
|
+
instance.bind_events(new_dom_element, new_vdom)
|
|
48
|
+
end
|
|
45
49
|
|
|
46
50
|
# Call component_updated on the child instance
|
|
47
51
|
instance.component_updated if instance.respond_to?(:component_updated)
|
|
@@ -54,6 +58,64 @@ module Funicular
|
|
|
54
58
|
old_vnode = patch[1]
|
|
55
59
|
unmount_component(old_vnode)
|
|
56
60
|
element.parentElement&.removeChild(element)
|
|
61
|
+
when :keyed_children
|
|
62
|
+
# Composite patch produced by Differ.diff_children_with_keys.
|
|
63
|
+
# Applied in three phases against `element`:
|
|
64
|
+
# 1. snapshot DOM children, then remove unmatched keyed old
|
|
65
|
+
# children (descending old_index so the snapshot indices
|
|
66
|
+
# remain valid as removes happen).
|
|
67
|
+
# 2. apply content updates to kept children in place. The
|
|
68
|
+
# lookup is by snapshot[old_index], so updates are stable
|
|
69
|
+
# regardless of subsequent insertions.
|
|
70
|
+
# 3. insert new children at their new_index using
|
|
71
|
+
# insertBefore on the live DOM. Processed in ascending
|
|
72
|
+
# new_index order so each insertion fixes its own
|
|
73
|
+
# position before later inserts run.
|
|
74
|
+
ops = patch[1]
|
|
75
|
+
removes = patch[2]
|
|
76
|
+
|
|
77
|
+
child_nodes = element[:childNodes]
|
|
78
|
+
snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
|
|
79
|
+
|
|
80
|
+
# Phase 1: removes (descending old_index)
|
|
81
|
+
sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
|
|
82
|
+
sorted_removes.each do |entry|
|
|
83
|
+
old_index = entry[0]
|
|
84
|
+
old_vnode = entry[1]
|
|
85
|
+
target = snapshot[old_index]
|
|
86
|
+
next if target.nil?
|
|
87
|
+
unmount_component(old_vnode)
|
|
88
|
+
parent_el = target.parentElement
|
|
89
|
+
parent_el.removeChild(target) if parent_el
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Phase 2: updates against the snapshot (no movement)
|
|
93
|
+
ops.each do |op|
|
|
94
|
+
next unless op[0] == :keep
|
|
95
|
+
old_index = op[1]
|
|
96
|
+
child_patches = op[3]
|
|
97
|
+
next if child_patches.empty?
|
|
98
|
+
target = snapshot[old_index]
|
|
99
|
+
next if target.nil?
|
|
100
|
+
apply(target, child_patches)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Phase 3: inserts in ascending new_index order
|
|
104
|
+
ops.each do |op|
|
|
105
|
+
next unless op[0] == :insert
|
|
106
|
+
new_index = op[1]
|
|
107
|
+
new_vnode = op[2]
|
|
108
|
+
new_node = create_element(new_vnode)
|
|
109
|
+
next if new_node.nil?
|
|
110
|
+
live_nodes = element[:childNodes]
|
|
111
|
+
live_arr = live_nodes.is_a?(JS::Object) ? live_nodes.to_a : [] #: Array[untyped]
|
|
112
|
+
ref = live_arr[new_index]
|
|
113
|
+
if ref.nil?
|
|
114
|
+
element.appendChild(new_node) if element.is_a?(JS::Element)
|
|
115
|
+
else
|
|
116
|
+
element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
57
119
|
when Integer
|
|
58
120
|
child_index = patch[0]
|
|
59
121
|
child_patches = patch[1]
|
|
@@ -67,7 +129,7 @@ module Funicular
|
|
|
67
129
|
case child_patch[0]
|
|
68
130
|
when :replace
|
|
69
131
|
new_child_element = create_element(child_patch[1])
|
|
70
|
-
element.appendChild(new_child_element)
|
|
132
|
+
element.appendChild(new_child_element) if element.is_a?(JS::Element)
|
|
71
133
|
when :remove
|
|
72
134
|
# Nothing to remove if child doesn't exist
|
|
73
135
|
when Integer
|
|
@@ -75,7 +137,9 @@ module Funicular
|
|
|
75
137
|
# But if it does, recursively process it
|
|
76
138
|
end
|
|
77
139
|
end
|
|
78
|
-
|
|
140
|
+
elsif child_element.is_a?(JS::Object)
|
|
141
|
+
# Recurse into the child node. apply handles both Element and
|
|
142
|
+
# text Node cases (the latter only meaningfully via :replace).
|
|
79
143
|
apply(child_element, child_patches)
|
|
80
144
|
end
|
|
81
145
|
end
|
|
@@ -91,6 +155,8 @@ module Funicular
|
|
|
91
155
|
end
|
|
92
156
|
|
|
93
157
|
def update_props(element, props_patch)
|
|
158
|
+
return unless element.is_a?(JS::Element)
|
|
159
|
+
|
|
94
160
|
props_patch.each do |key, value|
|
|
95
161
|
key_str = key.to_s
|
|
96
162
|
|
|
@@ -121,8 +187,10 @@ module Funicular
|
|
|
121
187
|
if BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
122
188
|
if value.nil? || value.to_s == "false"
|
|
123
189
|
element.removeAttribute(key_str)
|
|
190
|
+
element[key_str] = false
|
|
124
191
|
else
|
|
125
192
|
element.setAttribute(key_str, key_str)
|
|
193
|
+
element[key_str] = true
|
|
126
194
|
end
|
|
127
195
|
elsif value.nil?
|
|
128
196
|
element.removeAttribute(key_str)
|
|
@@ -166,8 +234,10 @@ module Funicular
|
|
|
166
234
|
element[:value] = value.to_s
|
|
167
235
|
elsif BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
168
236
|
if value.nil? || value.to_s == "false"
|
|
237
|
+
element[key_str] = false
|
|
169
238
|
else
|
|
170
239
|
element.setAttribute(key_str, key_str)
|
|
240
|
+
element[key_str] = true
|
|
171
241
|
end
|
|
172
242
|
else
|
|
173
243
|
element.setAttribute(key_str, value.to_s)
|
data/mrblib/router.rb
CHANGED
|
@@ -44,8 +44,22 @@ module Funicular
|
|
|
44
44
|
@default_route = path
|
|
45
45
|
end
|
|
46
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
|
+
|
|
47
53
|
# Start listening to popstate
|
|
48
|
-
|
|
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
|
+
|
|
49
63
|
# Clean up existing listener if any (prevents duplicate registration)
|
|
50
64
|
if @popstate_callback_id
|
|
51
65
|
JS::Object.removeEventListener(@popstate_callback_id)
|
|
@@ -57,8 +71,10 @@ module Funicular
|
|
|
57
71
|
handle_route_change
|
|
58
72
|
end
|
|
59
73
|
|
|
60
|
-
# Handle initial route
|
|
61
|
-
|
|
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
|
|
62
78
|
# Use replaceState to not add a new entry to the history
|
|
63
79
|
JS.global.history.replaceState(JS::Bridge.to_js({}), '', @default_route)
|
|
64
80
|
end
|
|
@@ -95,6 +111,11 @@ module Funicular
|
|
|
95
111
|
def handle_route_change
|
|
96
112
|
path = current_location_path
|
|
97
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
|
+
|
|
98
119
|
# Find matching route
|
|
99
120
|
component_class, params = find_route(path)
|
|
100
121
|
|
|
@@ -113,6 +134,22 @@ module Funicular
|
|
|
113
134
|
@current_path = path
|
|
114
135
|
@current_component = component_class.new(params)
|
|
115
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
|
+
|
|
116
153
|
@current_component.mount(@container)
|
|
117
154
|
end
|
|
118
155
|
|
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
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Store
|
|
3
|
+
# Collection store: an ordered Array per scope, with bounded size and a
|
|
4
|
+
# key proc that supports remove(id) / same_tail? semantics.
|
|
5
|
+
#
|
|
6
|
+
# class MessageCache < Funicular::Store::Collection
|
|
7
|
+
# database "funicular_message_cache"
|
|
8
|
+
# scope :channel_id
|
|
9
|
+
# limit 100
|
|
10
|
+
# key ->(m) { m["id"] }
|
|
11
|
+
# cleared_on :logout
|
|
12
|
+
#
|
|
13
|
+
# subscribes_to "ChatChannel",
|
|
14
|
+
# params: ->(s) { { channel: "ChatChannel", channel_id: s.channel_id } } do |data, _scope|
|
|
15
|
+
# case data["type"]
|
|
16
|
+
# when "initial_messages" then replace(data["messages"] || [])
|
|
17
|
+
# when "new_message" then append(data["message"])
|
|
18
|
+
# when "delete_message" then remove(data["message_id"])
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
class Collection < Store
|
|
23
|
+
DEFAULT_KEY_PROC = ->(item) {
|
|
24
|
+
item.is_a?(Hash) ? item["id"] : nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :__limit, :__order, :__key_proc
|
|
29
|
+
|
|
30
|
+
def limit(n)
|
|
31
|
+
@__limit = n
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :append (default) keeps the most recent items at the tail and caps
|
|
35
|
+
# by truncating the head; :prepend caps by truncating the tail.
|
|
36
|
+
def order(direction)
|
|
37
|
+
@__order = direction.to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def key(proc)
|
|
41
|
+
@__key_proc = proc
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scope_class
|
|
45
|
+
Funicular::Store::Collection::Scope
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Scope < Funicular::Store::Scope
|
|
50
|
+
def all
|
|
51
|
+
rec = read
|
|
52
|
+
return [] unless rec.is_a?(Hash)
|
|
53
|
+
if expired_record?(rec)
|
|
54
|
+
erase
|
|
55
|
+
return []
|
|
56
|
+
end
|
|
57
|
+
items = rec["items"]
|
|
58
|
+
items.is_a?(Array) ? items : []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def replace(arr)
|
|
62
|
+
new_arr = cap(arr.is_a?(Array) ? arr : [])
|
|
63
|
+
# Skip IndexedDB write if the cached snapshot already matches by
|
|
64
|
+
# tail. Always fire callback so subscribers know replace completed
|
|
65
|
+
# (e.g. to clear loading state).
|
|
66
|
+
unless same_tail?(new_arr)
|
|
67
|
+
write(new_arr)
|
|
68
|
+
end
|
|
69
|
+
fire_change(new_arr)
|
|
70
|
+
new_arr
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def append(item)
|
|
74
|
+
new_arr = cap(append_to(all, item))
|
|
75
|
+
write(new_arr)
|
|
76
|
+
fire_change(new_arr)
|
|
77
|
+
new_arr
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def remove(id)
|
|
81
|
+
cur = all
|
|
82
|
+
kp = key_proc
|
|
83
|
+
new_arr = cur.reject { |m| kp.call(m) == id }
|
|
84
|
+
return cur if new_arr.size == cur.size
|
|
85
|
+
write(new_arr)
|
|
86
|
+
fire_change(new_arr)
|
|
87
|
+
new_arr
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def last
|
|
91
|
+
arr = all
|
|
92
|
+
arr.empty? ? nil : arr[arr.size - 1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def last_id
|
|
96
|
+
l = last
|
|
97
|
+
return nil unless l
|
|
98
|
+
key_proc.call(l)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def size
|
|
102
|
+
all.size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def clear
|
|
106
|
+
erase
|
|
107
|
+
fire_change([])
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def expired?
|
|
112
|
+
rec = read
|
|
113
|
+
expired_record?(rec)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# True iff `other` matches the current cached snapshot by size and
|
|
117
|
+
# last-item key. Cheap staleness probe used by callers that already
|
|
118
|
+
# have a fresh server response and want to skip a redundant
|
|
119
|
+
# state-replace re-render.
|
|
120
|
+
def same_tail?(other)
|
|
121
|
+
return false unless other.is_a?(Array)
|
|
122
|
+
cur = all
|
|
123
|
+
return false if cur.size != other.size
|
|
124
|
+
return true if cur.empty? && other.empty?
|
|
125
|
+
kp = key_proc
|
|
126
|
+
kp.call(cur[cur.size - 1]) == kp.call(other[other.size - 1])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def key_proc
|
|
132
|
+
@store_class.__key_proc || Funicular::Store::Collection::DEFAULT_KEY_PROC
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cap(arr)
|
|
136
|
+
lim = @store_class.__limit
|
|
137
|
+
return arr unless lim.is_a?(Integer) && lim < arr.size
|
|
138
|
+
if @store_class.__order == :prepend
|
|
139
|
+
arr[0, lim] || arr
|
|
140
|
+
else
|
|
141
|
+
arr[arr.size - lim, lim] || arr
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def append_to(arr, item)
|
|
146
|
+
if @store_class.__order == :prepend
|
|
147
|
+
[item] + arr
|
|
148
|
+
else
|
|
149
|
+
arr + [item]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read
|
|
154
|
+
kvs[storage_key]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def write(items)
|
|
158
|
+
kvs[storage_key] = {
|
|
159
|
+
"items" => items,
|
|
160
|
+
"wrote_at" => now_seconds,
|
|
161
|
+
"expires_in" => @store_class.__expires_in
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def erase
|
|
166
|
+
kvs.delete(storage_key)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|