funicular 0.3.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56720ac2a4a7635871515e66f55bf5ed272bb49cb7cc3a403671494720ef16d7
4
- data.tar.gz: 7bf29c93a9480501c53e9b10cb33d29eb73ec22964300aed1c3e124206a3ab40
3
+ metadata.gz: 3daa358d6b29db11190349ae990006246a9c4b1d04e2e363235428dd3e53fd2a
4
+ data.tar.gz: 4ef7607d5e6d33385b776c6f01ba763c555bdc453109adb7d3473089889827cc
5
5
  SHA512:
6
- metadata.gz: 297df4162ffbe5259e2fe5c7738a310714a42ff3e1704386560c65d27e9a6d5aa19f5374e700915b388e37f9879926e2454296e9a39bfee4080d04310e2df295
7
- data.tar.gz: c56295e03efd38afc789b876717f3ce05a0631f02197843362ff627084db12fc3ecdd925b9c8ce437dbc8cff1b6fca9ff9b2b9396a5c22e24fd203fbcf50740d
6
+ metadata.gz: 547f30c0948941c1b40c2506c129e7aaa1d364b5db0073a2fe7f404d34149e2ba6d9400067748b6512beb7ffa34ba9dd318a9d6b6b9d78caf2baeeffc45f0442
7
+ data.tar.gz: b2c64be4ae511ead4abc54df652e391aafeea9e6ef1be3373e5dc686bac00d7de0885ffca2d4ad392dbb76c0f9fc7443ebfc367b35d22e8470766010669c5493
data/CHANGELOG.md CHANGED
@@ -1,4 +1,489 @@
1
- ## [Unreleased]
1
+ ## [0.5.0] - 2026-08-13
2
+
3
+ The local database release: an ActiveRecord-like, reactive local store on
4
+ in-browser SQLite (picoruby-sqlite3), with the Rails server as the source
5
+ of truth. Plus the first round of fixes and API gaps surfaced by building
6
+ a real shop on the framework.
7
+
8
+ ### Added
9
+
10
+ - `Funicular::StyleValue#+`: styles now support one-off class
11
+ additions (`styles.field + " col-span-2"`) instead of raising
12
+ NoMethodError. `+` concatenates verbatim like String#+, accepts
13
+ String or StyleValue, and raises TypeError for anything else
14
+ (matching String#+ instead of silently to_s-ing mistakes); `|`
15
+ remains the space-joining combinator. `to_str` is deliberately not
16
+ defined: the mruby client's String#+ never coerces implicitly, so
17
+ defining it on CRuby would let SSR accept `"base " + styles.field`
18
+ while the browser raises; both VMs reject that form identically
19
+ instead.
20
+
21
+ - Navigation guard: a component can veto leaving by overriding
22
+ `navigation_guard` to return a confirmation message (nil allows).
23
+ The router consults it before `navigate` (including `link_to
24
+ navigate: true`), on browser back/forward (restoring the history
25
+ entry when the user stays), and through a synchronous `beforeunload`
26
+ listener for reload / tab close via the browser's native dialog.
27
+ `Funicular.confirm_handler=` injects the dialog for tests or custom
28
+ UIs; SSR never blocks. The guard must not suspend.
29
+
30
+ - `Funicular::SSR.render_component(component_name, props:, state:)`:
31
+ render one component to static HTML with no route lookup, so a
32
+ server-rendered (ERB) page can embed a Funicular component -- a shared
33
+ site header, say -- instead of duplicating its markup. The component
34
+ is named by string and resolved after `boot!` (host apps keep
35
+ app/funicular out of Rails autoloading); a constant that is not a
36
+ `Funicular::Component` subclass is rejected with ArgumentError. No
37
+ hydration and no handler binding: links work, onclick does not.
38
+
39
+ - `Funicular::Testing::DOMTest` gains the negative assertions
40
+ `assert_no_selector` / `assert_no_text` and a `selector_count` helper;
41
+ "this must NOT render" was previously untestable without hand-rolled
42
+ JS.eval node counting.
43
+
44
+ - `Funicular::Testing.ensure_compiled!`: one call in a test helper that
45
+ syncs plugin assets and recompiles app.mrb when sources are newer,
46
+ replacing the boilerplate every host app grew by hand. Plain
47
+ controller tests that render `funicular_plugin_include_tags` no
48
+ longer fail order-dependently on unsynced plugin CSS.
49
+
50
+ - `Funicular::HTTP::Response#body` as an alias of `#data`: components
51
+ reach for the universal name first, and the resulting NoMethodError
52
+ used to vanish inside the JS bridge as a silently frozen page.
53
+
54
+ - The SQLite local-database subsystem is now globally opt-in through
55
+ `config.local_database = true` and defaults off. REST-only applications do
56
+ not start SQLite, IndexedDB, Web Locks, replica write-through, or session
57
+ epochs. Opted-in applications must also declare `config.user_key` or
58
+ `config.anonymous_only = true`; existing snapshots are retained while the
59
+ feature is disabled.
60
+
61
+ - Design documentation for the local database layer:
62
+ `docs/local_database.md` is the user-facing API contract (source-of-truth
63
+ contract, `storage`/`refresh` declarations, `migrate` blocks, `.local`
64
+ Relations, `watch`, persistence/durability, namespaces and tabs, session
65
+ epoch, SSR constraints); `docs/architecture.md` gains the
66
+ contributor-facing invariants.
67
+ - `Model.all(params)` now forwards `params` as a percent-encoded query
68
+ string (`Post.all(page: 2)` -> `GET /posts?page=2`) via the new
69
+ picoruby-uri gem's CRuby-compatible `URI.encode_www_form`. The argument
70
+ existed before but was silently ignored.
71
+ - The local-query foundation (`mrblib/db.rb`, `mrblib/relation.rb`):
72
+ `Funicular::Relation`, the lazy chainable query builder behind `.local`
73
+ (where/order/limit/offset; hash, IN, BETWEEN, IS NULL, and raw-fragment
74
+ conditions; each/to_a/first/count/exists?/find/find_by/delete_all), the
75
+ `Funicular::DB` error vocabulary, and the shared boolean/datetime codec
76
+ (`true`/`false` <-> 1/0, `Time` <-> UTC ISO 8601 TEXT) used on both the
77
+ SQLite and REST boundaries. The model-layer wiring (`storage`, `.local`)
78
+ arrives in a following change; the gem now depends on picoruby-sqlite3.
79
+ - The model declaration DSL: `storage :replica (default) | :ephemeral |
80
+ :local do ... end` (with `migrate N [, reset: true] do |t| ... end`
81
+ blocks recorded at class eval and version rules -- baseline and
82
+ contiguity -- validated there), `refresh :manual` (`:auto`/`:live`
83
+ raise "not yet supported"), `table_name` (naive pluralization +
84
+ override), and the `.local` entry point returning a whole-table
85
+ `Funicular::Relation` (NoTableError on ephemeral models). Replica
86
+ column metadata derives from the server schema (binary attributes
87
+ excluded); materializing a query before `Funicular::DB.boot` (a later
88
+ change) raises `Funicular::DB::UnavailableError`.
89
+ - Associations: `belongs_to :user` and `has_many :comments` as local-query
90
+ sugar over the `<name>_id` convention -- `post.user` reads
91
+ `User.local.find_by(id: post.user_id)`, `post.comments` returns the
92
+ chainable `Comment.local.where(post_id: post.id)` Relation (usable in
93
+ `watch`). `class_name:` and `foreign_key:` override the conventions;
94
+ `through:`, eager loading, and polymorphic associations raise as
95
+ unsupported in v1. Targets resolve lazily at first read, so model files
96
+ may load in any order, and a declaration whose name collides with a
97
+ column, a REST attribute, another declaration, or a `Funicular::Model`
98
+ instance method is refused instead of silently shadowing it.
99
+ - The client-only-table migration machinery: `Funicular::DB::TableBuilder`
100
+ (the `t` in migrate blocks -- string/text/integer/float/boolean/datetime
101
+ columns with `default:`/`null:`, `timestamps`, `index`/`remove_index`,
102
+ `rename`, `remove`, raw `execute`) and the per-table runner
103
+ (`Funicular::DB.apply_local_migrations`): fresh and below-baseline
104
+ tables rebuild from the baseline -- the newest `reset: true` block, or
105
+ the first block; superseded pre-reset history may stay in the code and
106
+ is never folded or applied -- upgrades apply exactly
107
+ the missing blocks in one transaction (rolled back on failure; in
108
+ development a failed upgrade auto-resets the table instead), applied
109
+ versions live in the `funicular_meta` table, and a table newer than
110
+ the declarations raises `Funicular::DB::SchemaTooNewError`. The column
111
+ fold is validated before any DDL runs, so declarations SQLite would
112
+ accept as plain DDL (renaming or removing the implicit `id`) are
113
+ rejected while the database is still intact. Local
114
+ models' `local_columns` now fold their migrate blocks (implicit
115
+ `id INTEGER PRIMARY KEY` included), replacing the interim
116
+ UnavailableError.
117
+ - The change-event bus (`mrblib/db.rb`): `Funicular::DB.subscribe`/
118
+ `unsubscribe` per [database role, table], and the raw-SQL protocol
119
+ `Funicular::DB.notify_changed(Model)` (or `(:local | :replica,
120
+ table)`; ephemeral models raise NoTableError). Events fire
121
+ post-commit only: inside a guarded transaction block they coalesce to
122
+ one event per [role, table] and flush after COMMIT, or vanish with
123
+ the rollback. Delivery is deferred to the NEXT tick (JS
124
+ `setTimeout(0)` by default; the scheduler is pluggable and CRuby
125
+ drains immediately, where no component can be mid-update), coalescing
126
+ per [role, table] within the tick; an event raised by a subscriber
127
+ belongs to the following tick -- never nested, never dropped -- and a
128
+ raising subscriber is isolated. `Model.local_table_changed` now feeds
129
+ this bus, so every framework write (local CRUD, delete_all, replica
130
+ write-through) announces itself.
131
+ - The writer election (`mrblib/db.rb`, docs decision 14): one tab per
132
+ namespace persists. `Funicular::DB.elect_writer` runs once at boot
133
+ with Web Locks' `ifAvailable` -- granted makes the tab the
134
+ `persistent_writer` (the lock is held by a promise resolved only at
135
+ `release_writer_lock`, the terminal step-down seam), not granted
136
+ makes it a `persistent_reader` for the life of the page (no
137
+ promotion in v1; reload to write), and a missing or failing Web
138
+ Locks API drops the page to `volatile` (everything works, nothing
139
+ persists). `Funicular::DB.durability` reports the state; the JS shim
140
+ accepts an injectable Locks API for tests.
141
+ - The persistence core (`mrblib/db.rb`, docs decisions 11/16):
142
+ whole-database snapshots (serialize -> Base64) in Funicular's OWN
143
+ IndexedDB store, opened with the in-memory fallback disabled --
144
+ availability errors (private mode) classify as the `volatile` state,
145
+ every other storage error stays loud for the boot to fail on.
146
+ Auto-persist rides the post-commit change-event funnel with a
147
+ per-role debounce (replica ~5 s, local ~500 ms; a rollback schedules
148
+ nothing), `Funicular::DB.flush` snapshots immediately (writer only;
149
+ `ReadOnlyTabError` on a reader, honest no-op on volatile), and a
150
+ `visibilitychange` backstop persists when the tab hides. A persist
151
+ landing while that database has an open transaction (a stale timer,
152
+ the backstop, an in-block flush) refuses to serialize uncommitted
153
+ pages and defers itself to the commit/rollback settle. Failures are
154
+ never silent: always logged, plus `config.on_persist_error`.
155
+ `Funicular::DB.configure` arrives with the persistence knobs
156
+ (`replica_debounce_ms`/`local_debounce_ms`/
157
+ `request_persistent_storage` -- `navigator.storage.persist()` is
158
+ asked only when local data exists -- and the
159
+ `on_persist_error`/`on_boot_error`/`on_session_change` hooks).
160
+ - `Funicular::DB.boot` (docs decision 19), the client-side boot that
161
+ wires everything in order: page metadata -> namespace resolution
162
+ (+ session epoch held for the HTTP layer) -> writer election ->
163
+ snapshot store (availability errors -> volatile) -> the two
164
+ `:memory:` connections -> local snapshot restore + migrations ->
165
+ replica restore + schema-derived DDL -> guarded handles installed
166
+ (`Funicular::DB.local`/`.replica`, and `Model.local_db`/`replica_db`
167
+ now consult the boot; reader tabs get `PRAGMA query_only=ON` plus a
168
+ read-only local proxy) -> `navigator.storage.persist()` when a
169
+ local model exists -> the visibilitychange backstop. One-shot;
170
+ raises `UnavailableError` under SSR. The handles gate on
171
+ `boot_state == :ready`, not on their existence: mid-boot (another
172
+ Task running during a boot await) and after a failed boot the
173
+ database is equally unreachable -- and the raw-database paths that
174
+ bypass the handles carry the gate too: `Model.reset_local` requires
175
+ `:ready`, `wipe` allows `:ready` or `:failed` (wiping from
176
+ `on_boot_error` is the official corrupt-snapshot recovery), and
177
+ `persist_snapshot` -- the final persistence entry that flush and
178
+ the debounce funnel through -- refuses during `:booting`, so a
179
+ mid-boot flush cannot overwrite stored snapshots with unrestored
180
+ databases. Any failure is decision 16's
181
+ fail loud: `boot_state` becomes `:failed`, the handles are torn
182
+ back out, the errors hit the console and `config.on_boot_error`,
183
+ nothing mounts (wired with `Funicular.start` in a following
184
+ change), and once the hook has had its recovery chance the writer
185
+ lock is released -- a failed page must not deny the writer slot to
186
+ every other tab. SchemaTooNew instead
187
+ completes the boot LOCKED DOWN (docs decision 7): every model-level
188
+ local operation raises `SchemaTooNewError`, raw SELECT export
189
+ through `DB.local` survives, writes are refused by SQLite itself.
190
+ `Model.reset_local` arrives with it: writer-only baseline rebuild of
191
+ one client-only table that lifts the lockdown once the whole
192
+ declared set passes again -- and the lift is provisional: a reset
193
+ that fails mid-rebuild puts SQLite's own write refusal
194
+ (`query_only`) back up before re-raising.
195
+ - The schema boot barrier and the start gate (docs decision 19,
196
+ wiring half). `Funicular.load_schemas` is now a real barrier: every
197
+ request settles its slot exactly once -- success, HTTP error, or a
198
+ schema that arrived but cannot be applied -- so it always
199
+ completes. All green boots the local database (declared models come
200
+ from a new Model registry filled at subclass definition; namespace,
201
+ epoch, and user-key metadata come from the include tag's
202
+ HTML-escaped `data-funicular-*` attributes) and only then runs the
203
+ completion block; any failure never invokes the block, reports
204
+ through the console and `config.on_boot_error`, and marks the boot
205
+ failed. `Funicular.start` gates on the boot before touching the
206
+ DOM: replica apps boot inside the barrier, local-only apps boot
207
+ right in start, and nothing mounts on top of a failed boot. An
208
+ empty schema set with a schema-less replica model declared fails
209
+ the boot loud instead of running on missing tables.
210
+ - The session-epoch terminal latch (docs decision 13, client half)
211
+ and HTTP's exactly-once settle. Every `Funicular::HTTP` request now
212
+ settles its callback exactly once: a rejected fetch (network
213
+ failure, invalid URL) delivers a status-0 error response instead of
214
+ hanging the schema barrier and every REST caller, and an exception
215
+ out of the caller's own block never settles twice. When the page
216
+ carried a session epoch, every response's `X-Funicular-Epoch` is
217
+ checked -- a rotated value OR a missing header means this page
218
+ belongs to a session that no longer exists: the response is
219
+ discarded (the caller settles with an error, nothing is applied)
220
+ and the page goes TERMINAL, irreversibly. From then on the page
221
+ refuses to ISSUE requests as well -- every verb settles immediately
222
+ with the same session-changed error before any fetch, since a
223
+ request executed under the new session's cookies could mutate
224
+ another user's data. A terminal writer steps
225
+ down completely: pending persist timers are cancelled, the final
226
+ persistence entry refuses forever, the writer lock frees the slot
227
+ for a fresh tab -- but only after an in-flight snapshot write has
228
+ landed, so a new writer can never race the old session's image --
229
+ and both database handles become a non-persistent read view. The
230
+ latch is independent of durability: `wipe` and `Model.reset_local`
231
+ -- the raw paths that bypass the read-only proxies -- refuse on ANY
232
+ terminated page, including a volatile one, which never steps down
233
+ to reader. A mismatch landing MID-BOOT (the boot suspends at the
234
+ writer election and at every storage read, with nothing to tear
235
+ down yet) aborts the boot through the ordinary failure funnel,
236
+ releasing a writer lock the election acquired after the
237
+ termination; as defense in depth, handles installed on a terminal
238
+ page come up read-only. `config.on_session_change` runs
239
+ once (default: `location.reload()`). The schema barrier arms the
240
+ page's epoch BEFORE its first request leaves, and the check itself
241
+ latches lazily off the page otherwise -- pre-boot HTTP (an
242
+ ephemeral model's REST call, a direct `HTTP.get` at app init) is
243
+ covered too, not only traffic after `DB.boot`, which alone would
244
+ latch too late. Pages without an epoch (no Rails integration yet)
245
+ are unaffected.
246
+ - The Rails half of data isolation and the session epoch (docs
247
+ decisions 12/13). `Funicular.configure` gains `application_id`
248
+ (default `"funicular"`; give each app sharing an origin its own),
249
+ `user_key` (a lambda receiving the controller and returning a
250
+ stable identifier, nil when signed out), and `anonymous_only` (the
251
+ explicit opt-out for apps without users) -- setting both is a
252
+ configuration error raised straight from the initializer. The
253
+ Railtie now stamps `X-Funicular-Epoch` on every response (emitted
254
+ lowercase, as the Rack 3 spec requires; HTTP header names are
255
+ case-insensitive on the wire): the epoch
256
+ lives in the Rails session PER application_id
257
+ (`session["funicular_epochs"]`) and rotates whenever the computed
258
+ user key changes, so login, logout, and direct user switches all
259
+ rotate it with no application code. Rotation runs in a controller
260
+ around_action (the user_key lambda needs its controller) that
261
+ stamps BEFORE the action and re-stamps in its ensure with the
262
+ post-action identity -- the login/logout actions flip the identity
263
+ mid-request, and their own response must already carry the rotated
264
+ epoch. The header itself is written by a Rack middleware sitting
265
+ ABOVE ActionDispatch's exception renderer: a controller-set header
266
+ dies with the controller's response when the action raises, and a
267
+ header-less 500 would read as an epoch mismatch client-side,
268
+ terminating a healthy page over a mere server error. Both the
269
+ concern and the include-tag helper read the session through
270
+ `request.session`, never the controller/view `session` accessor: an
271
+ application action named "session" shadows that accessor, and
272
+ calling it would invoke the action itself. Session-less
273
+ Rails API apps stay unbroken: a disabled session leaves the epoch
274
+ feature off (no cookie identity exists to protect) instead of
275
+ raising on every action. `picoruby_include_tag` embeds
276
+ the namespace + epoch metadata as HTML-escaped `data-funicular-*`
277
+ attributes on the bootstrap script tag -- exactly the contract
278
+ `DB.read_page_metadata` reads client-side -- with the user-key
279
+ attribute omitted for signed-out visitors and the epoch drawn from
280
+ the same session entry the response header uses; the user-key
281
+ attribute and the epoch identity come from ONE resolver evaluation,
282
+ so a racy `current_user` cannot embed one user's namespace with
283
+ another user's epoch. A `user_key` that resolves to an empty string
284
+ fails loud server-side.
285
+ - `Funicular::DB.wipe` and the mutation generation (docs decision 17):
286
+ one call drops every table in both databases of the current
287
+ namespace, deletes its two snapshot keys, rebuilds the replica DDL +
288
+ fingerprint and the local migration state from scratch, and notifies
289
+ watchers only once the tables are queryable again AND the stale
290
+ snapshots are really gone (components re-render onto empty tables,
291
+ never onto missing ones; a failing snapshot delete raises out of
292
+ wipe before any watcher is told). Writer-only
293
+ (`ReadOnlyTabError` on a reader; fine on volatile, where there are
294
+ no snapshots to delete). The wipe is safe mid-flight: it advances
295
+ the mutation generation FIRST, so REST responses issued before it
296
+ are discarded -- the callback gets `(nil, Funicular::DB::Error)`
297
+ instead of resurrecting the previous session's rows -- pending
298
+ persistence timers are cancelled, and an in-progress snapshot
299
+ cannot overwrite the cleared state. While either database has an
300
+ open transaction, wipe refuses loudly BEFORE any side effect: the
301
+ rebuild would otherwise nest into (or be rolled back with) that
302
+ transaction. The check cannot be raced, either: wipe never suspends
303
+ its Task between the check and the end of the rebuild -- the
304
+ snapshot deletes, the only awaiting operations, come last.
305
+ - The reactivity layer on top of the bus: `Component#watch(:key)` binds
306
+ a state key to a `storage :local`/`.local` Relation -- the block runs
307
+ once, materializes into `state[:key]`, and re-runs (re-subscribing,
308
+ so branchy blocks may switch relations) after every change event on
309
+ the relation's table; anything that is not a Relation raises, pointing
310
+ at `Model.on_change`/`off_change`, the public primitive for hashes,
311
+ counts, and raw-SQL-derived state. Watch subscriptions die with the
312
+ component even when a lifecycle hook raises.
313
+ - The guarded database handles (`mrblib/db.rb`,
314
+ `Funicular::DB::GuardedDatabase`/`GuardedStatement`/
315
+ `GuardedResultSet`): the proxies `Funicular::DB.local`/`.replica`
316
+ will hand out instead of raw connections. The allowlist is closed --
317
+ persist/close/serialize/deserialize/backup do not exist in any state,
318
+ `transaction` yields the proxy itself, and `query` returns a wrapped
319
+ result set. Read-only handles enforce at EVERY execution entry
320
+ (execute, step, ResultSet next/reset) via `Statement#readonly?` (a
321
+ write prepared while writable is still refused after the handle went
322
+ read-only, one-way), raising
323
+ `Funicular::DB::ReadOnlyTabError`; ATTACH/DETACH and
324
+ `PRAGMA query_only` are rejected in every state, comment prefixes
325
+ included, while read pragmas stay available.
326
+ - The namespace identity (`mrblib/db.rb`): a typed, versioned tuple
327
+ (`["v1", app, "anonymous"]` / `["v1", app, "user", key]`) encoded as
328
+ canonical JSON, which every durable name -- the two snapshot keys and
329
+ the Web Lock name -- derives from. Structure, not delimiters,
330
+ separates the fields, so a user_key of "anonymous" or one containing
331
+ separators cannot collide. `resolve_namespace` enforces the
332
+ declaration rules client-side (`Funicular::DB::ConfigError`):
333
+ user_key and anonymous_only are mutually exclusive, and every opted-in
334
+ application requires a user_key unless anonymous_only explicitly accepts
335
+ one shared anonymous namespace.
336
+ - REST is wired to the local database layer: response values decode
337
+ through the shared codec when instances initialize and when `update`
338
+ applies the server row (ISO 8601 strings become `Time`, 1/0 become
339
+ booleans -- `Post.all` and `Post.local.find` now return the same Ruby
340
+ types), and every successful REST call mirrors its result into the
341
+ replica through the single apply entry point BEFORE user callbacks
342
+ run (`all`/`find`/`create` upsert, `update` upserts the applied
343
+ server row, `destroy` deletes). Write-through stays inert until
344
+ `Funicular::DB.boot` installs the replica handle, so REST keeps
345
+ working standalone.
346
+ - The replica-table plumbing (`mrblib/db.rb`): CREATE TABLE derived from
347
+ the server schema (id type follows the server -- INTEGER or TEXT; a
348
+ schema without id raises pointing at `storage :ephemeral`; binary
349
+ attributes never reach the replica), the canonical-JSON schema
350
+ fingerprint stored in `funicular_meta` (string equality; a mismatch
351
+ drops and recreates ALL replica tables empty, refilled by the app's
352
+ next explicit fetch), and the single write-through entry points
353
+ `replica_upsert` (whole-row INSERT OR REPLACE through the codec) and
354
+ `replica_delete` (RETURNING-based), both firing the model's change
355
+ hook. Boot wiring and the REST call sites arrive next.
356
+ - Local CRUD and the bare-class alias on `storage :local` models:
357
+ synchronous, validated `create` (id from the inserted row; omitted
358
+ attributes take the SQL DEFAULT while an explicit nil binds NULL; the
359
+ row is read back, so defaults and codec normalization land in the
360
+ instance; auto `created_at`/`updated_at`), `#update`
361
+ (true/false; an update with no actual changes is a no-op that does
362
+ not touch `updated_at`), `#destroy`, `#reload`, `#new_record?`;
363
+ `Draft.all` is the whole-table Relation (blocks and params raise --
364
+ there is no REST side), and `where`/`order`/`limit`/`offset`/`count`/
365
+ `first`/`exists?`/`find_by`/`delete_all` hang off the bare class,
366
+ which on other storage kinds points you at `.local`. All local writes
367
+ fire the `local_table_changed` hook and let SQLite constraint
368
+ violations escape as `SQLite3::Exception`. `Model.create` now also
369
+ accepts bare keywords (`Draft.create(title: "x")`) on every storage
370
+ kind.
371
+
372
+ ### Changed (BREAKING)
373
+
374
+ - Every `Funicular::Model` REST callback is now uniformly
375
+ `(result, error)`: on success `result` is the payload (`all` -> array,
376
+ `find`/`create` -> instance, `update` -> the applied instance, `destroy`
377
+ -> `true`) and `error` is nil; on failure `result` is nil. `update` and
378
+ `destroy` used to yield boolean-first `(true/false, data_or_error)`;
379
+ callsites reading the first argument as a boolean must be updated.
380
+ `update` with nothing to send (no changes, or binary-only changes) now
381
+ reports a successful no-op instead of silently not calling the block.
382
+
383
+ ### Fixed
384
+
385
+ - `Model#initialize` uses key-presence lookups instead of `||`, so a
386
+ string-keyed `false` (boolean columns) no longer collapses to nil.
387
+
388
+ - Dev-mode SSR reloads edited component sources instead of caching them
389
+ per process (the railtie enables it in development): SSR markup no
390
+ longer goes stale behind the middleware's recompiled app.mrb until a
391
+ server restart. Concurrent renders serialize the reload, and a file
392
+ vanishing mid-edit does not break the mtime check.
393
+
394
+ - Event-handler and HTTP-callback exceptions name the component and
395
+ handler on the console before re-raising, instead of an anonymous
396
+ "Callback <id>" line -- or, for HTTP callbacks, nothing at all.
397
+
398
+ - `Schema.build` skips validator introspection for `readonly: true`
399
+ attributes: server-managed columns no longer fail client-side
400
+ validation against values the client never edits.
401
+
402
+ - `Schema::RegexpTranslator` unescapes Ruby's `\#` identity escape, which
403
+ survives in `Regexp#source` but is rejected by the JS RegExp engine under
404
+ the `u` flag (`URI::MailTo::EMAIL_REGEXP` is the common casualty: one such
405
+ validator used to fail the whole client boot).
406
+
407
+ - `Schema.serialize` now carries `allow_nil` / `allow_blank` through to the
408
+ client -- including kinds that serialize to a bare `true`, such as
409
+ `presence` and unconstrained `numericality`, which upgrade to a Hash --
410
+ so a validator on an optional attribute no longer rejects the nil or
411
+ blank value the server accepts.
412
+
413
+ - A schema `format` regex the client runtime cannot compile downgrades to a
414
+ console warning and drops that one validator instead of failing the whole
415
+ schema load.
416
+
417
+ - `FileUpload.upload_with_formdata` attaches the CSRF token from the
418
+ page's meta tag (Rails forgery protection rejected every upload) and
419
+ accepts a `method:` keyword instead of hard-coding PATCH.
420
+
421
+ ### Removed
422
+
423
+ - The IndexedDB-backed HTTP response cache (`Funicular::HTTP` `cache:`
424
+ option, `cache_purge`, `cache_clear`). It was dead code -- no caller
425
+ anywhere passed `cache:` -- and the local database layer is this
426
+ release's answer to caching. Structured data belongs in replica tables,
427
+ not keyed response bodies.
428
+
429
+ ## [0.4.0] - 2026-07-23
430
+
431
+ ### Added
432
+
433
+ - 0.4.0 bareword component DSL: `render` (zero-arity) runs with `self` as
434
+ the component, so HTML tags, `component`, `form_for`, `link_to`,
435
+ `button_to`, `suspense`, `state`, `props`, `styles`, `resources`, and
436
+ `routes` are all called bareword, without the 0.3.0 `h.` receiver.
437
+ - DSL collision detection: tag and helper names are reserved inside
438
+ component classes. Defining one raises `Funicular::DSLCollisionError` at
439
+ class-definition time (`method_added`) or at first mount
440
+ (`validate_dsl_conflicts!`, covering `attr_*` on mruby and included
441
+ modules). `allow_dsl_override :name` opts out per class; the shadowed
442
+ element stays reachable via `tag(:name, ...)`.
443
+ - Bareword style definitions: the class-level `styles do ... end` block
444
+ runs on a `BasicObject` cleanroom builder, so any name (including
445
+ `display`, `hash`, ...) defines a style identically on mruby and CRuby.
446
+ The explicit `styles { |css| css.define(...) }` form remains for
447
+ computed values.
448
+ - Generated style accessors: each declared style name becomes a real
449
+ method on a per-component accessor, e.g. `styles.button(:disabled)`;
450
+ the `styles[:name, variant]` form is kept.
451
+
452
+ ### Breaking Changes
453
+
454
+ - **0.4.0 is a breaking DSL change against 0.3.0. Components written for
455
+ 0.3.0 migrate mechanically: delete the `render(h)` parameter, drop the
456
+ `h.` receivers, convert `css.define :name, "..."` to bareword
457
+ `name "..."`, and `h.styles[:name, variant]` to
458
+ `styles.name(variant)`.**
459
+ - `p` inside a component builds a `<p>` element. Debug with
460
+ `puts x.inspect`; a non-Hash argument to any tag raises `ArgumentError`
461
+ with a hint.
462
+ - A local variable named after a tag shadows the zero-paren call form
463
+ (plain Ruby scoping); write `option()` or rename the local.
464
+ - Tag and helper names (RESERVED_DSL) can no longer be defined as
465
+ component methods without `allow_dsl_override`.
466
+ - Style lookups of unknown names raise (`NoMethodError` for
467
+ `styles.typo`, `ArgumentError` for `styles[:typo]`) instead of
468
+ silently returning an empty class string. Style definition values are
469
+ validated (String / Hash / keyword options; unknown option keys raise).
470
+ - Tag helpers called while the component is not rendering raise
471
+ `Funicular::RenderContextError` instead of being silently dropped.
472
+ - `ErrorBoundary` `fallback:`/`error:` procs keep an explicit view
473
+ context (`->(h, error) { h.div { ... } }`): they are created in the
474
+ parent's scope but run during the boundary's render, so barewords
475
+ cannot work there by design.
476
+ - `Component#render_suspense` no longer takes a view context; suspense
477
+ `fallback:`/`error:`/content procs run bareword in their own component
478
+ (`fallback: -> { div { "Loading" } }`).
479
+
480
+ ### Changed
481
+
482
+ - Requires picoruby-wasm with `JS::Object < BasicObject` (picoruby
483
+ 9e69333f): Kernel names (`hash`, `send`, `open`, ...) no longer shadow
484
+ JS property access, and unknown `?`/`!` methods on JS values raise.
485
+
486
+ ## [0.3.0] - 2026-07-13
2
487
 
3
488
  ### Added
4
489