funicular 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +429 -1
  3. data/demo/local_notes.html +207 -0
  4. data/docs/architecture.md +181 -3
  5. data/docs/local_database.md +1035 -0
  6. data/lib/funicular/assets/funicular.rb +14 -0
  7. data/lib/funicular/configuration.rb +65 -0
  8. data/lib/funicular/epoch_header.rb +69 -0
  9. data/lib/funicular/epoch_stamping.rb +66 -0
  10. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  11. data/lib/funicular/railtie.rb +30 -0
  12. data/lib/funicular/schema.rb +45 -12
  13. data/lib/funicular/session_epoch.rb +110 -0
  14. data/lib/funicular/ssr/runtime.rb +57 -12
  15. data/lib/funicular/ssr.rb +25 -0
  16. data/lib/funicular/testing/node_runner.mjs +19 -0
  17. data/lib/funicular/testing.rb +47 -0
  18. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  19. data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
  20. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
  23. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/version.rb +1 -1
  30. data/lib/funicular.rb +1 -0
  31. data/lib/tasks/funicular.rake +10 -2
  32. data/minitest/callback_error_visibility_test.rb +48 -0
  33. data/minitest/configuration_test.rb +78 -0
  34. data/minitest/dsl_test.rb +27 -0
  35. data/minitest/epoch_header_test.rb +149 -0
  36. data/minitest/epoch_stamping_test.rb +225 -0
  37. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  38. data/minitest/navigation_guard_test.rb +65 -0
  39. data/minitest/picoruby_helper_test.rb +236 -0
  40. data/minitest/schema_test.rb +47 -0
  41. data/minitest/session_epoch_test.rb +122 -0
  42. data/minitest/ssr_database_test.rb +78 -0
  43. data/minitest/ssr_reload_test.rb +106 -0
  44. data/minitest/ssr_test.rb +41 -0
  45. data/minitest/testing_ensure_compiled_test.rb +52 -0
  46. data/minitest/validations_test.rb +35 -5
  47. data/mrbgem.rake +2 -0
  48. data/mrblib/cable.rb +1 -1
  49. data/mrblib/component.rb +113 -1
  50. data/mrblib/db.rb +3116 -0
  51. data/mrblib/file_upload.rb +17 -7
  52. data/mrblib/funicular.rb +136 -17
  53. data/mrblib/http.rb +84 -107
  54. data/mrblib/model.rb +1178 -23
  55. data/mrblib/relation.rb +342 -0
  56. data/mrblib/router.rb +45 -4
  57. data/mrblib/styles.rb +20 -0
  58. data/sig/component.rbs +7 -0
  59. data/sig/db.rbs +328 -0
  60. data/sig/funicular.rbs +5 -0
  61. data/sig/http.rbs +8 -21
  62. data/sig/model.rbs +101 -7
  63. data/sig/relation.rbs +44 -0
  64. data/sig/router.rbs +1 -0
  65. data/sig/styles.rbs +1 -0
  66. metadata +19 -2
  67. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5816e8e43d54e6bec4b6bb8a06da6165ab138e549cdbad649d2acba002a5fec4
4
- data.tar.gz: 51ed5ca984f12669bfd1587bf384ea685c54e2d03dff9552d3cc49485d29081f
3
+ metadata.gz: 3daa358d6b29db11190349ae990006246a9c4b1d04e2e363235428dd3e53fd2a
4
+ data.tar.gz: 4ef7607d5e6d33385b776c6f01ba763c555bdc453109adb7d3473089889827cc
5
5
  SHA512:
6
- metadata.gz: 360c39bdc7375ae5967c37f0b24437fa6d254f8814520ae984cc455195ac957b1279988a430f752e731eb40eab62cbeba178bc1ad08dc8836c7ce64c0d0810d5
7
- data.tar.gz: 31bb97a1b6fbedbf3d1b1539c3e8ea5a8169c91095733c09d38f465c463a56071ecb766524945e307b66998708e09fff0ce0f6388e009f54b9d9945169922259
6
+ metadata.gz: 547f30c0948941c1b40c2506c129e7aaa1d364b5db0073a2fe7f404d34149e2ba6d9400067748b6512beb7ffa34ba9dd318a9d6b6b9d78caf2baeeffc45f0442
7
+ data.tar.gz: b2c64be4ae511ead4abc54df652e391aafeea9e6ef1be3373e5dc686bac00d7de0885ffca2d4ad392dbb76c0f9fc7443ebfc367b35d22e8470766010669c5493
data/CHANGELOG.md CHANGED
@@ -1,4 +1,432 @@
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
2
430
 
3
431
  ### Added
4
432
 
@@ -0,0 +1,207 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Local Notes - Funicular</title>
6
+ </head>
7
+ <body>
8
+ <div><a href="../index.html">Back</a></div>
9
+ <h1>Local Notes (Funicular local database)</h1>
10
+ <!-- A page declaring storage :local models must choose its
11
+ namespace explicitly (docs decision 12) or the boot fails
12
+ loud. A Rails app gets these attributes from
13
+ picoruby_include_tag; this static page carries them by hand. -->
14
+ <div id="app"
15
+ data-funicular-local-database="true"
16
+ data-funicular-application-id="local_notes_demo"
17
+ data-funicular-anonymous-only="true"></div>
18
+
19
+ <div class="explain">
20
+ <p>A server-less demo of the local database layer:
21
+ <code>storage :local</code>, <code>.local</code> queries,
22
+ <code>watch</code>, snapshot persistence, the single-writer
23
+ election, and <code>Funicular::DB.wipe</code>.</p>
24
+ <ul>
25
+ <li><b>Add some notes, then reload the page.</b> The writer tab
26
+ snapshots the database into IndexedDB about half a second
27
+ after the last write, and the next visit restores it.</li>
28
+ <li><b>Open this page in a second tab.</b> Only one tab per
29
+ browser profile wins the writer election; the other becomes a
30
+ persistent reader whose writes raise. Close the writer tab
31
+ and reload the reader to take over.</li>
32
+ <li><b>Press "Wipe everything".</b> Both databases of this
33
+ namespace are dropped and rebuilt empty, the stored snapshots
34
+ are deleted, and the watcher re-renders onto empty tables.</li>
35
+ <li>The list re-renders through
36
+ <code>watch(:notes) { Note.local.order(created_at: :desc) }</code>
37
+ after every change event; no manual state bookkeeping.</li>
38
+ </ul>
39
+ </div>
40
+
41
+ <script type="text/ruby">
42
+ class Note < Funicular::Model
43
+ table_name "local_notes"
44
+ storage :local do
45
+ migrate 1 do |t|
46
+ t.string :title
47
+ t.timestamps
48
+ end
49
+ end
50
+ end
51
+
52
+ class NotesApp < Funicular::Component
53
+ READER_MESSAGE =
54
+ "This tab is a persistent reader: another tab holds the " \
55
+ "writer lock. Close the writer tab and reload to write here."
56
+
57
+ def initialize_state
58
+ # start boots the database before anything mounts, so the
59
+ # durability verdict is already final here.
60
+ {
61
+ notes: [],
62
+ message: "",
63
+ durability: Funicular::DB.durability,
64
+ }
65
+ end
66
+
67
+ def component_mounted
68
+ watch(:notes) { Note.local.order(created_at: :desc) }
69
+ end
70
+
71
+ def handle_add(event)
72
+ event.preventDefault
73
+ field = JS.document.getElementById("note-title")
74
+ title = field[:value].to_s
75
+ return if title.empty?
76
+ begin
77
+ Note.create(title: title)
78
+ field[:value] = ""
79
+ patch(message: "")
80
+ rescue Funicular::DB::ReadOnlyTabError
81
+ patch(message: READER_MESSAGE)
82
+ end
83
+ end
84
+
85
+ def handle_delete(note)
86
+ -> {
87
+ begin
88
+ note.destroy
89
+ patch(message: "")
90
+ rescue Funicular::DB::ReadOnlyTabError
91
+ patch(message: READER_MESSAGE)
92
+ end
93
+ }
94
+ end
95
+
96
+ def handle_wipe(event)
97
+ event.preventDefault
98
+ begin
99
+ Funicular::DB.wipe
100
+ patch(message: "Wiped: tables rebuilt empty, snapshots deleted.")
101
+ rescue Funicular::DB::ReadOnlyTabError
102
+ patch(message: READER_MESSAGE)
103
+ end
104
+ end
105
+
106
+ def render
107
+ notes = state[:notes]
108
+ div(class: "notes-app") do
109
+ p(class: "status") do
110
+ "durability: #{state[:durability]} | notes: #{notes.size}"
111
+ end
112
+ div(class: "composer") do
113
+ # A placeholder disappears as soon as text is typed, so it
114
+ # cannot be the field's accessible name.
115
+ label(for: "note-title") { "Note title" }
116
+ input(type: "text", id: "note-title",
117
+ placeholder: "What to remember?")
118
+ button(onclick: :handle_add) { "Add" }
119
+ button(onclick: :handle_wipe, class: "danger") do
120
+ "Wipe everything"
121
+ end
122
+ end
123
+ p(class: "message") { state[:message] }
124
+ ul(class: "notes") do
125
+ notes.each do |note|
126
+ li(key: note.id) do
127
+ span(class: "title") { note.title }
128
+ span(class: "stamp") { note.created_at.to_s }
129
+ button(onclick: handle_delete(note), class: "delete") do
130
+ "delete"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ # No load_schemas round: a local-only app boots right inside
140
+ # start's gate. The namespace comes from the hand-written
141
+ # data-funicular-* attributes on the #app container above: pages
142
+ # with storage :local models must declare anonymous_only (or a
143
+ # user key) explicitly -- a silent shared default would merge
144
+ # every user's data, so the boot fails loud without it.
145
+ Funicular.start(NotesApp, container: 'app')
146
+ </script>
147
+ <script src="../init.iife.js"></script>
148
+ </body>
149
+ <style>
150
+ .notes-app {
151
+ max-width: 560px;
152
+ }
153
+
154
+ .status {
155
+ color: #555;
156
+ font-family: monospace;
157
+ }
158
+
159
+ .composer input {
160
+ margin-right: 6px;
161
+ padding: 4px 6px;
162
+ width: 260px;
163
+ }
164
+
165
+ .composer button {
166
+ margin-right: 6px;
167
+ }
168
+
169
+ .composer .danger {
170
+ color: #a00;
171
+ }
172
+
173
+ .message {
174
+ color: #a00;
175
+ min-height: 1.2em;
176
+ }
177
+
178
+ ul.notes {
179
+ list-style: none;
180
+ padding: 0;
181
+ }
182
+
183
+ ul.notes li {
184
+ border-bottom: 1px solid #ddd;
185
+ display: flex;
186
+ gap: 10px;
187
+ padding: 6px 2px;
188
+ }
189
+
190
+ ul.notes .title {
191
+ flex: 1;
192
+ }
193
+
194
+ ul.notes .stamp {
195
+ color: #888;
196
+ font-size: 0.85em;
197
+ }
198
+
199
+ .explain {
200
+ border-top: 1px solid #ccc;
201
+ color: #333;
202
+ margin-top: 24px;
203
+ max-width: 640px;
204
+ padding-top: 12px;
205
+ }
206
+ </style>
207
+ </html>