funicular 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -0,0 +1,342 @@
1
+ # A lazy, chainable query over one local table (docs/local_database.md,
2
+ # "Querying"). Chain builders (where/order/limit/offset) each return a NEW
3
+ # Relation; SQL executes once, in a materializer.
4
+ #
5
+ # The `model` handed to the constructor is the query's metadata + row
6
+ # factory. Relation calls exactly these methods on it:
7
+ #
8
+ # table_name -> String
9
+ # local_columns -> Hash[String => Symbol] (column name -> declared type)
10
+ # local_db -> handle responding to execute(sql, binds) -> rows
11
+ # replica? -> bool (delete_all guard)
12
+ # build_from_local -> (Hash) -> model instance (attrs already decoded)
13
+ # local_table_changed -> void; called after a framework-managed write
14
+ # (delete_all here) so the model can fire change
15
+ # events and schedule snapshot persistence
16
+ #
17
+ # Identifiers in hash conditions and order() are validated against
18
+ # local_columns and double-quoted; values cross into SQL through
19
+ # Funicular::DB::Codec. Raw SQL fragments pass through untouched, their
20
+ # binds encoded by value (Codec.encode_bind).
21
+
22
+ module Funicular
23
+ class Relation
24
+ # The trailing arguments are internal: chain builders use them to spawn
25
+ # derived relations. Application code passes only `model`.
26
+ def initialize(model, where_sql = [], where_binds = [], order_sql = [],
27
+ limit = nil, offset = nil)
28
+ @model = model
29
+ @where_sql = where_sql
30
+ @where_binds = where_binds
31
+ @order_sql = order_sql
32
+ @limit = limit
33
+ @offset = offset
34
+ end
35
+
36
+ # ---- chain builders --------------------------------------------------
37
+
38
+ # where(title: "x") equality (nil -> IS NULL)
39
+ # where(id: [1, 2]) IN (empty array -> WHERE 1=0)
40
+ # where(id: 1..9) BETWEEN (exclusive end -> >= AND <)
41
+ # where("title LIKE ?", "a%") raw fragment with placeholders
42
+ # Multiple where calls AND together.
43
+ def where(conditions = nil, *binds)
44
+ w = @where_sql.dup
45
+ b = @where_binds.dup
46
+ if conditions.is_a?(Hash)
47
+ keys = conditions.keys
48
+ i = 0
49
+ while i < keys.size
50
+ append_condition(w, b, keys[i], conditions[keys[i]])
51
+ i += 1
52
+ end
53
+ elsif conditions.is_a?(String)
54
+ w << "(#{conditions})"
55
+ i = 0
56
+ while i < binds.size
57
+ b << Funicular::DB::Codec.encode_bind(binds[i])
58
+ i += 1
59
+ end
60
+ else
61
+ raise ArgumentError,
62
+ "where expects a Hash or a SQL fragment String, got #{conditions.inspect}"
63
+ end
64
+ Relation.new(@model, w, b, @order_sql, @limit, @offset)
65
+ end
66
+
67
+ # order(:created_at) ASC
68
+ # order(created_at: :desc)
69
+ # order(:pinned, created_at: :desc) multiple keys
70
+ def order(*args)
71
+ if args.empty?
72
+ raise ArgumentError, "order requires at least one column"
73
+ end
74
+ o = @order_sql.dup
75
+ i = 0
76
+ while i < args.size
77
+ arg = args[i]
78
+ if arg.is_a?(Hash)
79
+ keys = arg.keys
80
+ j = 0
81
+ while j < keys.size
82
+ o << order_term(keys[j], arg[keys[j]])
83
+ j += 1
84
+ end
85
+ else
86
+ o << order_term(arg, :asc)
87
+ end
88
+ i += 1
89
+ end
90
+ Relation.new(@model, @where_sql, @where_binds, o, @limit, @offset)
91
+ end
92
+
93
+ def limit(n)
94
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
95
+ slice_arg(n, "limit"), @offset)
96
+ end
97
+
98
+ def offset(n)
99
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
100
+ @limit, slice_arg(n, "offset"))
101
+ end
102
+
103
+ # ---- materializers ---------------------------------------------------
104
+
105
+ def to_a
106
+ rows = @model.local_db.execute(select_sql, @where_binds)
107
+ cols = @model.local_columns.keys
108
+ # @type var out: Array[untyped]
109
+ out = []
110
+ i = 0
111
+ while i < rows.size
112
+ out << @model.build_from_local(decode_row(cols, rows[i]))
113
+ i += 1
114
+ end
115
+ out
116
+ end
117
+
118
+ def each
119
+ list = to_a
120
+ i = 0
121
+ while i < list.size
122
+ yield list[i]
123
+ i += 1
124
+ end
125
+ list
126
+ end
127
+
128
+ # Instance or nil. Narrows the window to one row (never widens: a
129
+ # relation already limited to 0 stays empty).
130
+ def first
131
+ lim = @limit
132
+ lim = (lim.nil? || 1 <= lim) ? 1 : lim
133
+ Relation.new(@model, @where_sql, @where_binds, @order_sql,
134
+ lim, @offset).to_a[0]
135
+ end
136
+
137
+ # SELECT COUNT(*); on a limited/offset relation it counts the window
138
+ # (COUNT over a subquery), matching ActiveRecord.
139
+ def count
140
+ sql = if @limit || @offset
141
+ "SELECT COUNT(*) FROM (#{select_sql})"
142
+ else
143
+ "SELECT COUNT(*) FROM #{quoted_table}#{where_clause}"
144
+ end
145
+ single_value(sql)
146
+ end
147
+
148
+ def exists?
149
+ if @limit || @offset
150
+ 0 < count
151
+ else
152
+ sql = "SELECT 1 FROM #{quoted_table}#{where_clause} LIMIT 1"
153
+ !@model.local_db.execute(sql, @where_binds).empty?
154
+ end
155
+ end
156
+
157
+ def find(id)
158
+ record = find_by(id: id)
159
+ unless record
160
+ raise Funicular::RecordNotFound,
161
+ "Couldn't find #{model_label} with id=#{id}"
162
+ end
163
+ record
164
+ end
165
+
166
+ def find_by(conditions)
167
+ where(conditions).first
168
+ end
169
+
170
+ # Bulk delete, `storage :local` models only; returns the number of
171
+ # deleted rows. Raises on a relation carrying order/limit/offset (say
172
+ # what you mean with a plain condition).
173
+ def delete_all
174
+ if @model.replica?
175
+ raise Funicular::DB::ReplicaWriteError,
176
+ "delete_all is not available on replica models; the server owns " \
177
+ "replica rows (deletions reach the replica through destroy)"
178
+ end
179
+ if @limit || @offset || !@order_sql.empty?
180
+ raise ArgumentError,
181
+ "delete_all does not support order/limit/offset"
182
+ end
183
+ # RETURNING counts the deletions inside the one statement; reading
184
+ # SELECT changes() afterwards would race other Tasks writing on the
185
+ # same connection between the two calls.
186
+ rows = @model.local_db.execute(
187
+ "DELETE FROM #{quoted_table}#{where_clause} RETURNING \"id\"",
188
+ @where_binds)
189
+ count = rows.size
190
+ # Framework-managed writes must notify (docs, "Querying"); a delete
191
+ # that removed nothing changed nothing.
192
+ @model.local_table_changed if 0 < count
193
+ count
194
+ end
195
+
196
+ # The SELECT this relation will run (debugging aid; binds not inlined).
197
+ def to_sql
198
+ select_sql
199
+ end
200
+
201
+ # Internal (Component#watch): where change events for this
202
+ # relation's rows come from, as [role, table].
203
+ def __event_source
204
+ m = @model
205
+ [m.replica? ? :replica : :local, m.table_name]
206
+ end
207
+
208
+ # ---- internal --------------------------------------------------------
209
+
210
+ private
211
+
212
+ def append_condition(w, b, col, value)
213
+ name = validate_column(col)
214
+ q = quote(name)
215
+ type = @model.local_columns[name]
216
+ codec = Funicular::DB::Codec
217
+ if value.nil?
218
+ w << "#{q} IS NULL"
219
+ elsif value.is_a?(Array)
220
+ if value.empty?
221
+ w << "1=0"
222
+ else
223
+ # @type var marks: Array[String]
224
+ marks = []
225
+ i = 0
226
+ while i < value.size
227
+ marks << "?"
228
+ b << codec.encode(type, value[i])
229
+ i += 1
230
+ end
231
+ w << "#{q} IN (#{marks.join(", ")})"
232
+ end
233
+ elsif value.is_a?(Range)
234
+ b << codec.encode(type, value.begin)
235
+ b << codec.encode(type, value.end)
236
+ if value.exclude_end?
237
+ w << "(#{q} >= ? AND #{q} < ?)"
238
+ else
239
+ w << "#{q} BETWEEN ? AND ?"
240
+ end
241
+ else
242
+ w << "#{q} = ?"
243
+ b << codec.encode(type, value)
244
+ end
245
+ end
246
+
247
+ def order_term(col, dir)
248
+ d = dir.to_s.downcase
249
+ unless d == "asc" || d == "desc"
250
+ raise ArgumentError,
251
+ "order direction must be :asc or :desc, got #{dir.inspect}"
252
+ end
253
+ "#{quote(validate_column(col))} #{d == "asc" ? "ASC" : "DESC"}"
254
+ end
255
+
256
+ def validate_column(col)
257
+ name = col.to_s
258
+ unless @model.local_columns.has_key?(name)
259
+ raise ArgumentError,
260
+ "unknown column #{name.inspect} for table \"#{@model.table_name}\""
261
+ end
262
+ name
263
+ end
264
+
265
+ def quote(name)
266
+ "\"#{name}\""
267
+ end
268
+
269
+ def quoted_table
270
+ quote(@model.table_name)
271
+ end
272
+
273
+ def select_sql
274
+ cols = @model.local_columns.keys
275
+ # @type var quoted: Array[String]
276
+ quoted = []
277
+ i = 0
278
+ while i < cols.size
279
+ quoted << quote(cols[i])
280
+ i += 1
281
+ end
282
+ "SELECT #{quoted.join(", ")} FROM #{quoted_table}" \
283
+ "#{where_clause}#{order_clause}#{slice_clause}"
284
+ end
285
+
286
+ def where_clause
287
+ w = @where_sql
288
+ w.empty? ? "" : " WHERE #{w.join(" AND ")}"
289
+ end
290
+
291
+ def order_clause
292
+ o = @order_sql
293
+ o.empty? ? "" : " ORDER BY #{o.join(", ")}"
294
+ end
295
+
296
+ # LIMIT -1 OFFSET n is how SQLite spells offset-without-limit.
297
+ def slice_clause
298
+ lim = @limit
299
+ off = @offset
300
+ if lim
301
+ off ? " LIMIT #{lim} OFFSET #{off}" : " LIMIT #{lim}"
302
+ elsif off
303
+ " LIMIT -1 OFFSET #{off}"
304
+ else
305
+ ""
306
+ end
307
+ end
308
+
309
+ def slice_arg(n, label)
310
+ return nil if n.nil?
311
+ unless n.is_a?(Integer) && 0 <= n
312
+ raise ArgumentError, "#{label} must be a non-negative Integer, got #{n.inspect}"
313
+ end
314
+ n
315
+ end
316
+
317
+ def decode_row(cols, row)
318
+ codec = Funicular::DB::Codec
319
+ columns = @model.local_columns
320
+ # @type var attrs: Hash[String, untyped]
321
+ attrs = {}
322
+ i = 0
323
+ while i < cols.size
324
+ name = cols[i]
325
+ raw = row.is_a?(Hash) ? row[name] : row[i]
326
+ attrs[name] = codec.decode(columns[name], raw)
327
+ i += 1
328
+ end
329
+ attrs
330
+ end
331
+
332
+ def single_value(sql)
333
+ row = @model.local_db.execute(sql, @where_binds)[0]
334
+ row.is_a?(Hash) ? row.values[0] : row[0]
335
+ end
336
+
337
+ def model_label
338
+ m = @model
339
+ m.respond_to?(:name) ? m.name : m.table_name
340
+ end
341
+ end
342
+ end
data/mrblib/router.rb CHANGED
@@ -9,6 +9,7 @@ module Funicular
9
9
  @current_component = nil
10
10
  @current_path = nil
11
11
  @popstate_callback_id = nil
12
+ @beforeunload_callback_id = nil
12
13
  @url_helpers = Module.new
13
14
  @route_helpers = Object.new
14
15
  @route_helpers.extend(@url_helpers)
@@ -62,15 +63,38 @@ module Funicular
62
63
 
63
64
  @hydrate_initial = hydrate
64
65
 
65
- # Clean up existing listener if any (prevents duplicate registration)
66
+ # Clean up existing listeners if any (prevents duplicate registration)
66
67
  if @popstate_callback_id
67
68
  JS::Object.removeEventListener(@popstate_callback_id)
68
69
  @popstate_callback_id = nil
69
70
  end
71
+ if @beforeunload_callback_id
72
+ JS::Object.removeEventListener(@beforeunload_callback_id)
73
+ @beforeunload_callback_id = nil
74
+ end
70
75
 
71
- # Set up popstate listener
76
+ # Set up popstate listener. The history entry has already moved by
77
+ # the time popstate fires; when the current component's navigation
78
+ # guard vetoes leaving, push the guarded path back (the one thing
79
+ # popstate cannot cancel).
72
80
  @popstate_callback_id = JS.global.addEventListener('popstate') do |event|
73
- handle_route_change
81
+ if leave_allowed?
82
+ handle_route_change
83
+ elsif (guarded_path = @current_path)
84
+ # Local binding: the type checker does not narrow ivars
85
+ # through elsif.
86
+ JS.global.history.pushState(JS::Bridge.to_js({}), '', guarded_path)
87
+ end
88
+ end
89
+
90
+ # Reload / tab close: ask through the browser's native dialog when
91
+ # a guard is active. sync: the decision must be made on the JS
92
+ # event dispatch stack, so the guard must not suspend.
93
+ @beforeunload_callback_id = JS.global.addEventListener('beforeunload', sync: true) do |event|
94
+ if @current_component&.navigation_guard
95
+ event.preventDefault
96
+ event[:returnValue] = ''
97
+ end
74
98
  end
75
99
 
76
100
  # Handle initial route. Skip the default-route redirect when hydrating
@@ -90,16 +114,33 @@ module Funicular
90
114
  @popstate_callback_id = nil
91
115
  end
92
116
 
117
+ if @beforeunload_callback_id
118
+ JS::Object.removeEventListener(@beforeunload_callback_id)
119
+ @beforeunload_callback_id = nil
120
+ end
121
+
93
122
  unmount_current_component
94
123
  end
95
124
 
96
- # Navigate to a path programmatically using History API
125
+ # Navigate to a path programmatically using History API. A vetoing
126
+ # navigation guard on the current component cancels the navigation
127
+ # before any history change.
97
128
  def navigate(path)
129
+ return unless leave_allowed?
98
130
  JS.global.history.pushState(JS::Bridge.to_js({}), '', path)
99
131
  # Manually trigger route change because pushState doesn't fire popstate
100
132
  handle_route_change
101
133
  end
102
134
 
135
+ # Ask the current component's navigation guard whether leaving is
136
+ # allowed; a String from the guard prompts the user via
137
+ # Funicular.confirm. True when no component or no guard.
138
+ def leave_allowed?
139
+ message = @current_component&.navigation_guard
140
+ return true unless message
141
+ Funicular.confirm(message)
142
+ end
143
+
103
144
  # Get current path from location
104
145
  def current_location_path
105
146
  js_path_obj = JS.global.location.pathname
data/mrblib/styles.rb CHANGED
@@ -20,6 +20,26 @@ module Funicular
20
20
  end
21
21
  end
22
22
 
23
+ # String-like concatenation: styles.field + " col-span-2". No space
24
+ # is inserted and non-String operands raise TypeError, matching
25
+ # String#+ (a silent to_s would hide mistakes like `+ 123`); use |
26
+ # to join with a space.
27
+ # to_str is deliberately not defined: the mruby client's String#+
28
+ # never coerces (no implicit to_str call), so defining it on CRuby
29
+ # would let SSR accept "base " + styles.field while the browser
30
+ # raises. Both VMs reject the reversed form the same way instead.
31
+ def +(other)
32
+ case other
33
+ when StyleValue
34
+ StyleValue.new(@value + other.value)
35
+ when String
36
+ StyleValue.new(@value + other)
37
+ else
38
+ other = other #: untyped
39
+ raise TypeError, "no implicit conversion of #{other.class} into String"
40
+ end
41
+ end
42
+
23
43
  def to_s
24
44
  @value
25
45
  end
data/sig/component.rbs CHANGED
@@ -47,6 +47,11 @@ module Funicular
47
47
  def initialize_state: () -> Hash[Symbol, untyped]
48
48
  def seed_state: (Hash[untyped, untyped]? state_hash) -> self
49
49
 
50
+ @__watches: Hash[Symbol, Integer]?
51
+ def watch: (Symbol key) { () -> untyped } -> nil
52
+ def evaluate_watch: (Symbol key, untyped block) -> nil
53
+ def cleanup_watches: () -> nil
54
+
50
55
  def load_suspense_data: () -> void
51
56
  def load_single_suspense: (Symbol name, ?suspense_definition? definition) -> void
52
57
  def reload_suspense: (Symbol name) -> void
@@ -55,6 +60,7 @@ module Funicular
55
60
  def suspense_error: (Symbol name) -> untyped
56
61
  def render_suspense: (Symbol name, fallback: untyped, ?error: untyped) { (ResourceAccessor) -> untyped } -> untyped
57
62
 
63
+ def navigation_guard: () -> String?
58
64
  def patch: (Hash[Symbol, untyped] new_state) -> void
59
65
  def mount: (JS::Element container) -> void
60
66
  def hydrate: (JS::Element dom_element) -> void
@@ -63,6 +69,7 @@ module Funicular
63
69
  def build_vdom: () -> (VDOM::VNode | VDOM::Text | nil)
64
70
 
65
71
  def bind_events: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode) -> void
72
+ def report_handler_error: (String event_name, String handler, StandardError error) -> void
66
73
  def collect_refs: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode, ?Hash[Symbol, JS::Element] refs_map) -> Hash[Symbol, JS::Element]
67
74
  def normalize_vnode_for_view: (untyped value) -> (VDOM::Element | VDOM::Text | VDOM::Component | nil)
68
75
  def __view__: () -> ViewContext