funicular 0.4.0 → 0.5.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 +473 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +82 -124
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +241 -126
- 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 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +4 -3
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +33 -17
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/debug.rb +54 -10
- data/mrblib/differ.rb +7 -5
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +137 -21
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/patcher.rb +57 -25
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/mrblib/vdom.rb +5 -5
- data/mrblib/version.rb +11 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/debug.rbs +5 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/patcher.rbs +1 -0
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +21 -3
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/patcher.rb
CHANGED
|
@@ -31,9 +31,13 @@ module Funicular
|
|
|
31
31
|
# Apply internal patches and get the potentially new root element
|
|
32
32
|
new_dom_element = Patcher.new(@doc, instance.runtime).apply(old_dom_element, internal_patches)
|
|
33
33
|
|
|
34
|
-
# Update the instance's reference to its root DOM element if it
|
|
34
|
+
# Update the instance's reference to its root DOM element if it
|
|
35
|
+
# changed. The new root must also become this apply's return
|
|
36
|
+
# value; otherwise a caller that stores the result (for example
|
|
37
|
+
# the keyed_children snapshot) keeps pointing at a detached node.
|
|
35
38
|
if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
|
|
36
39
|
instance.dom_element = new_dom_element
|
|
40
|
+
result = new_dom_element
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Update the instance's VDOM to the new one AFTER applying patches
|
|
@@ -65,18 +69,23 @@ module Funicular
|
|
|
65
69
|
# 1. snapshot DOM children, then remove unmatched keyed old
|
|
66
70
|
# children (descending old_index so the snapshot indices
|
|
67
71
|
# remain valid as removes happen).
|
|
68
|
-
# 2. apply content updates to kept children
|
|
72
|
+
# 2. apply content updates to kept children. The
|
|
69
73
|
# lookup is by snapshot[old_index], so updates are stable
|
|
70
|
-
# regardless of
|
|
71
|
-
# 3.
|
|
72
|
-
# insertBefore on the live DOM.
|
|
73
|
-
# new_index order so each
|
|
74
|
-
#
|
|
74
|
+
# regardless of removals.
|
|
75
|
+
# 3. place kept and new children at their new_index using
|
|
76
|
+
# insertBefore on the live DOM. Ops are already in ascending
|
|
77
|
+
# new_index order, so each placement fixes the next position.
|
|
78
|
+
#
|
|
79
|
+
# Phase 3 tracks the live child order in a Ruby array instead of
|
|
80
|
+
# re-reading childNodes per op. Every childNodes.to_a allocates a
|
|
81
|
+
# fresh JS::Object wrapper per node, so wrappers of the same DOM
|
|
82
|
+
# node never compare equal and the read itself is O(n) across the
|
|
83
|
+
# wasm boundary. Identity is therefore decided with equal? on the
|
|
84
|
+
# snapshot objects, which is both exact and free.
|
|
75
85
|
ops = patch[1]
|
|
76
86
|
removes = patch[2]
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
|
|
88
|
+
snapshot = child_nodes_array(element)
|
|
80
89
|
|
|
81
90
|
# Phase 1: removes (descending old_index)
|
|
82
91
|
sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
|
|
@@ -88,9 +97,10 @@ module Funicular
|
|
|
88
97
|
unmount_component(old_vnode)
|
|
89
98
|
parent_el = target.parentElement
|
|
90
99
|
parent_el.removeChild(target) if parent_el
|
|
100
|
+
snapshot[old_index] = nil
|
|
91
101
|
end
|
|
92
102
|
|
|
93
|
-
# Phase 2: updates against the snapshot
|
|
103
|
+
# Phase 2: updates against the snapshot
|
|
94
104
|
ops.each do |op|
|
|
95
105
|
next unless op[0] == :keep
|
|
96
106
|
old_index = op[1]
|
|
@@ -98,31 +108,45 @@ module Funicular
|
|
|
98
108
|
next if child_patches.empty?
|
|
99
109
|
target = snapshot[old_index]
|
|
100
110
|
next if target.nil?
|
|
101
|
-
apply(target, child_patches)
|
|
111
|
+
snapshot[old_index] = apply(target, child_patches)
|
|
102
112
|
end
|
|
103
113
|
|
|
104
|
-
# Phase 3: inserts in ascending new_index order
|
|
114
|
+
# Phase 3: moves and inserts in ascending new_index order.
|
|
115
|
+
# `live` mirrors the DOM child order after phases 1 and 2 and is
|
|
116
|
+
# updated alongside every DOM mutation, so a node already sitting
|
|
117
|
+
# at new_index is left untouched (no detach/re-attach).
|
|
118
|
+
live = snapshot.compact #: Array[untyped]
|
|
105
119
|
ops.each do |op|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
case op[0]
|
|
121
|
+
when :keep
|
|
122
|
+
new_node = snapshot[op[1]]
|
|
123
|
+
new_index = op[2]
|
|
124
|
+
when :insert
|
|
125
|
+
new_index = op[1]
|
|
126
|
+
new_node = create_element(op[2])
|
|
127
|
+
else
|
|
128
|
+
next
|
|
129
|
+
end
|
|
110
130
|
next if new_node.nil?
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if
|
|
115
|
-
|
|
131
|
+
current = live[new_index]
|
|
132
|
+
next if current.equal?(new_node)
|
|
133
|
+
live.delete_if { |n| n.equal?(new_node) }
|
|
134
|
+
if new_index < live.length
|
|
135
|
+
live.insert(new_index, new_node)
|
|
116
136
|
else
|
|
117
|
-
|
|
137
|
+
live << new_node
|
|
138
|
+
end
|
|
139
|
+
next unless element.is_a?(JS::Element)
|
|
140
|
+
if current.nil?
|
|
141
|
+
element.appendChild(new_node)
|
|
142
|
+
else
|
|
143
|
+
element.insertBefore(new_node, current)
|
|
118
144
|
end
|
|
119
145
|
end
|
|
120
146
|
when Integer
|
|
121
147
|
child_index = patch[0]
|
|
122
148
|
child_patches = patch[1]
|
|
123
|
-
|
|
124
|
-
child_nodes = element[:childNodes]
|
|
125
|
-
children = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[JS::Object]
|
|
149
|
+
children = child_nodes_array(element)
|
|
126
150
|
child_element = children[child_index]
|
|
127
151
|
if child_element.nil?
|
|
128
152
|
# No existing child at this index - we need to create new elements
|
|
@@ -150,6 +174,14 @@ module Funicular
|
|
|
150
174
|
|
|
151
175
|
private
|
|
152
176
|
|
|
177
|
+
# childNodes (not children) so that text nodes are included. Each call
|
|
178
|
+
# crosses the wasm boundary once per child, so callers should read it
|
|
179
|
+
# once and work on the returned array.
|
|
180
|
+
def child_nodes_array(element)
|
|
181
|
+
child_nodes = element[:childNodes]
|
|
182
|
+
child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
|
|
183
|
+
end
|
|
184
|
+
|
|
153
185
|
def unmount_component(vnode)
|
|
154
186
|
return unless vnode.is_a?(VDOM::Component) && vnode.instance
|
|
155
187
|
vnode.instance.unmount
|
data/mrblib/relation.rb
ADDED
|
@@ -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
|
|
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
|
-
|
|
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/mrblib/vdom.rb
CHANGED
|
@@ -100,6 +100,11 @@ module Funicular
|
|
|
100
100
|
@children = normalize_children(children || [])
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def ==(other)
|
|
104
|
+
return false unless other.is_a?(Element)
|
|
105
|
+
@tag == other.tag && @props == other.props && @children == other.children
|
|
106
|
+
end
|
|
107
|
+
|
|
103
108
|
private
|
|
104
109
|
|
|
105
110
|
def normalize_children(children)
|
|
@@ -124,11 +129,6 @@ module Funicular
|
|
|
124
129
|
end
|
|
125
130
|
result
|
|
126
131
|
end
|
|
127
|
-
|
|
128
|
-
def ==(other)
|
|
129
|
-
return false unless other.is_a?(Element)
|
|
130
|
-
@tag == other.tag && @props == other.props && @children == other.children
|
|
131
|
-
end
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
class Text < VNode
|
data/mrblib/version.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Single source of truth for the Funicular version.
|
|
2
|
+
#
|
|
3
|
+
# This file lives in mrblib so that it is compiled into PicoRuby.wasm along
|
|
4
|
+
# with the rest of the runtime. The CRuby gem reuses it via
|
|
5
|
+
# lib/funicular/version.rb, so the version is defined in exactly one place.
|
|
6
|
+
#
|
|
7
|
+
# Keep this file free of any dependency: it must evaluate standalone in
|
|
8
|
+
# both PicoRuby and CRuby, regardless of load order.
|
|
9
|
+
module Funicular
|
|
10
|
+
VERSION = '0.5.1'
|
|
11
|
+
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
|