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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3daa358d6b29db11190349ae990006246a9c4b1d04e2e363235428dd3e53fd2a
|
|
4
|
+
data.tar.gz: 4ef7607d5e6d33385b776c6f01ba763c555bdc453109adb7d3473089889827cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 547f30c0948941c1b40c2506c129e7aaa1d364b5db0073a2fe7f404d34149e2ba6d9400067748b6512beb7ffa34ba9dd318a9d6b6b9d78caf2baeeffc45f0442
|
|
7
|
+
data.tar.gz: b2c64be4ae511ead4abc54df652e391aafeea9e6ef1be3373e5dc686bac00d7de0885ffca2d4ad392dbb76c0f9fc7443ebfc367b35d22e8470766010669c5493
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,432 @@
|
|
|
1
|
-
## [
|
|
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>
|