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