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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +12 -4
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +25 -18
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/form_for_test.rb +106 -0
  45. data/minitest/hydration_test.rb +87 -0
  46. data/minitest/plugin_test.rb +51 -0
  47. data/minitest/schema_test.rb +106 -0
  48. data/minitest/ssr_test.rb +94 -0
  49. data/minitest/validations_test.rb +183 -0
  50. data/mrbgem.rake +1 -0
  51. data/mrblib/0_validations.rb +206 -0
  52. data/mrblib/1_validators.rb +180 -0
  53. data/mrblib/cable.rb +24 -9
  54. data/mrblib/component.rb +246 -47
  55. data/mrblib/debug.rb +3 -0
  56. data/mrblib/differ.rb +47 -37
  57. data/mrblib/file_upload.rb +9 -1
  58. data/mrblib/form_builder.rb +28 -6
  59. data/mrblib/funicular.rb +97 -8
  60. data/mrblib/html_serializer.rb +121 -0
  61. data/mrblib/http.rb +123 -29
  62. data/mrblib/model.rb +50 -0
  63. data/mrblib/patcher.rb +78 -8
  64. data/mrblib/router.rb +40 -3
  65. data/mrblib/store.rb +304 -0
  66. data/mrblib/store_collection.rb +171 -0
  67. data/mrblib/store_singleton.rb +79 -0
  68. data/mrblib/vdom.rb +2 -0
  69. data/sig/cable.rbs +1 -0
  70. data/sig/component.rbs +13 -5
  71. data/sig/funicular.rbs +14 -1
  72. data/sig/html_serializer.rbs +20 -0
  73. data/sig/http.rbs +21 -6
  74. data/sig/model.rbs +6 -1
  75. data/sig/patcher.rbs +4 -1
  76. data/sig/router.rbs +3 -2
  77. data/sig/store.rbs +89 -0
  78. data/sig/store_collection.rbs +43 -0
  79. data/sig/store_singleton.rbs +19 -0
  80. data/sig/validations.rbs +103 -0
  81. data/sig/vdom.rbs +6 -6
  82. metadata +48 -12
  83. data/docs/README.md +0 -419
  84. data/docs/advanced-features.md +0 -632
  85. data/docs/components-and-state.md +0 -539
  86. data/docs/data-fetching.md +0 -528
  87. data/docs/forms.md +0 -446
  88. data/docs/rails-integration.md +0 -426
  89. data/docs/realtime.md +0 -543
  90. data/docs/routing-and-navigation.md +0 -427
  91. 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
- update_props(element, patch[1])
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
- instance.collect_refs(new_dom_element, new_vdom)
42
+ if new_dom_element.is_a?(JS::Element)
43
+ instance.collect_refs(new_dom_element, new_vdom)
41
44
 
42
- # Re-bind events using the new DOM element
43
- instance.cleanup_events
44
- instance.bind_events(new_dom_element, new_vdom)
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
- else
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
- def start
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
- if current_location_path == '/' && @default_route
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