The local database release: an ActiveRecord-like, reactive local store on in-browser SQLite (picoruby-sqlite3), with the Rails server as the source of truth. Plus the first round of fixes and API gaps surfaced by building a real shop on the framework.
-
Funicular::StyleValue#+: styles now support one-off class additions (styles.field + " col-span-2") instead of raising NoMethodError.+concatenates verbatim like String#+, accepts String or StyleValue, and raises TypeError for anything else (matching String#+ instead of silently to_s-ing mistakes);|remains the space-joining combinator.to_stris deliberately not defined: the mruby client's String#+ never coerces implicitly, so defining it on CRuby would let SSR accept"base " + styles.fieldwhile the browser raises; both VMs reject that form identically instead. -
Navigation guard: a component can veto leaving by overriding
navigation_guardto return a confirmation message (nil allows). The router consults it beforenavigate(includinglink_to navigate: true), on browser back/forward (restoring the history entry when the user stays), and through a synchronousbeforeunloadlistener for reload / tab close via the browser's native dialog.Funicular.confirm_handler=injects the dialog for tests or custom UIs; SSR never blocks. The guard must not suspend. -
Funicular::SSR.render_component(component_name, props:, state:): render one component to static HTML with no route lookup, so a server-rendered (ERB) page can embed a Funicular component -- a shared site header, say -- instead of duplicating its markup. The component is named by string and resolved afterboot!(host apps keep app/funicular out of Rails autoloading); a constant that is not aFunicular::Componentsubclass is rejected with ArgumentError. No hydration and no handler binding: links work, onclick does not. -
Funicular::Testing::DOMTestgains the negative assertionsassert_no_selector/assert_no_textand aselector_counthelper; "this must NOT render" was previously untestable without hand-rolled JS.eval node counting. -
Funicular::Testing.ensure_compiled!: one call in a test helper that syncs plugin assets and recompiles app.mrb when sources are newer, replacing the boilerplate every host app grew by hand. Plain controller tests that renderfunicular_plugin_include_tagsno longer fail order-dependently on unsynced plugin CSS. -
Funicular::HTTP::Response#bodyas an alias of#data: components reach for the universal name first, and the resulting NoMethodError used to vanish inside the JS bridge as a silently frozen page. -
The SQLite local-database subsystem is now globally opt-in through
config.local_database = trueand defaults off. REST-only applications do not start SQLite, IndexedDB, Web Locks, replica write-through, or session epochs. Opted-in applications must also declareconfig.user_keyorconfig.anonymous_only = true; existing snapshots are retained while the feature is disabled. -
Design documentation for the local database layer:
docs/local_database.mdis the user-facing API contract (source-of-truth contract,storage/refreshdeclarations,migrateblocks,.localRelations,watch, persistence/durability, namespaces and tabs, session epoch, SSR constraints);docs/architecture.mdgains the contributor-facing invariants. -
Model.all(params)now forwardsparamsas a percent-encoded query string (Post.all(page: 2)->GET /posts?page=2) via the new picoruby-uri gem's CRuby-compatibleURI.encode_www_form. The argument existed before but was silently ignored. -
The local-query foundation (
mrblib/db.rb,mrblib/relation.rb):Funicular::Relation, the lazy chainable query builder behind.local(where/order/limit/offset; hash, IN, BETWEEN, IS NULL, and raw-fragment conditions; each/to_a/first/count/exists?/find/find_by/delete_all), theFunicular::DBerror vocabulary, and the shared boolean/datetime codec (true/false<-> 1/0,Time<-> UTC ISO 8601 TEXT) used on both the SQLite and REST boundaries. The model-layer wiring (storage,.local) arrives in a following change; the gem now depends on picoruby-sqlite3. -
The model declaration DSL:
storage :replica (default) | :ephemeral | :local do ... end(withmigrate N [, reset: true] do |t| ... endblocks recorded at class eval and version rules -- baseline and contiguity -- validated there),refresh :manual(:auto/:liveraise "not yet supported"),table_name(naive pluralization + override), and the.localentry point returning a whole-tableFunicular::Relation(NoTableError on ephemeral models). Replica column metadata derives from the server schema (binary attributes excluded); materializing a query beforeFunicular::DB.boot(a later change) raisesFunicular::DB::UnavailableError. -
Associations:
belongs_to :userandhas_many :commentsas local-query sugar over the<name>_idconvention --post.userreadsUser.local.find_by(id: post.user_id),post.commentsreturns the chainableComment.local.where(post_id: post.id)Relation (usable inwatch).class_name:andforeign_key:override the conventions;through:, eager loading, and polymorphic associations raise as unsupported in v1. Targets resolve lazily at first read, so model files may load in any order, and a declaration whose name collides with a column, a REST attribute, another declaration, or aFunicular::Modelinstance method is refused instead of silently shadowing it. -
The client-only-table migration machinery:
Funicular::DB::TableBuilder(thetin migrate blocks -- string/text/integer/float/boolean/datetime columns withdefault:/null:,timestamps,index/remove_index,rename,remove, rawexecute) and the per-table runner (Funicular::DB.apply_local_migrations): fresh and below-baseline tables rebuild from the baseline -- the newestreset: trueblock, or the first block; superseded pre-reset history may stay in the code and is never folded or applied -- upgrades apply exactly the missing blocks in one transaction (rolled back on failure; in development a failed upgrade auto-resets the table instead), applied versions live in thefunicular_metatable, and a table newer than the declarations raisesFunicular::DB::SchemaTooNewError. The column fold is validated before any DDL runs, so declarations SQLite would accept as plain DDL (renaming or removing the implicitid) are rejected while the database is still intact. Local models'local_columnsnow fold their migrate blocks (implicitid INTEGER PRIMARY KEYincluded), replacing the interim UnavailableError. -
The change-event bus (
mrblib/db.rb):Funicular::DB.subscribe/unsubscribeper [database role, table], and the raw-SQL protocolFunicular::DB.notify_changed(Model)(or(:local | :replica, table); ephemeral models raise NoTableError). Events fire post-commit only: inside a guarded transaction block they coalesce to one event per [role, table] and flush after COMMIT, or vanish with the rollback. Delivery is deferred to the NEXT tick (JSsetTimeout(0)by default; the scheduler is pluggable and CRuby drains immediately, where no component can be mid-update), coalescing per [role, table] within the tick; an event raised by a subscriber belongs to the following tick -- never nested, never dropped -- and a raising subscriber is isolated.Model.local_table_changednow feeds this bus, so every framework write (local CRUD, delete_all, replica write-through) announces itself. -
The writer election (
mrblib/db.rb, docs decision 14): one tab per namespace persists.Funicular::DB.elect_writerruns once at boot with Web Locks'ifAvailable-- granted makes the tab thepersistent_writer(the lock is held by a promise resolved only atrelease_writer_lock, the terminal step-down seam), not granted makes it apersistent_readerfor the life of the page (no promotion in v1; reload to write), and a missing or failing Web Locks API drops the page tovolatile(everything works, nothing persists).Funicular::DB.durabilityreports the state; the JS shim accepts an injectable Locks API for tests. -
The persistence core (
mrblib/db.rb, docs decisions 11/16): whole-database snapshots (serialize -> Base64) in Funicular's OWN IndexedDB store, opened with the in-memory fallback disabled -- availability errors (private mode) classify as thevolatilestate, every other storage error stays loud for the boot to fail on. Auto-persist rides the post-commit change-event funnel with a per-role debounce (replica ~5 s, local ~500 ms; a rollback schedules nothing),Funicular::DB.flushsnapshots immediately (writer only;ReadOnlyTabErroron a reader, honest no-op on volatile), and avisibilitychangebackstop persists when the tab hides. A persist landing while that database has an open transaction (a stale timer, the backstop, an in-block flush) refuses to serialize uncommitted pages and defers itself to the commit/rollback settle. Failures are never silent: always logged, plusconfig.on_persist_error.Funicular::DB.configurearrives with the persistence knobs (replica_debounce_ms/local_debounce_ms/request_persistent_storage--navigator.storage.persist()is asked only when local data exists -- and theon_persist_error/on_boot_error/on_session_changehooks). -
Funicular::DB.boot(docs decision 19), the client-side boot that wires everything in order: page metadata -> namespace resolution (+ session epoch held for the HTTP layer) -> writer election -> snapshot store (availability errors -> volatile) -> the two:memory:connections -> local snapshot restore + migrations -> replica restore + schema-derived DDL -> guarded handles installed (Funicular::DB.local/.replica, andModel.local_db/replica_dbnow consult the boot; reader tabs getPRAGMA query_only=ONplus a read-only local proxy) ->navigator.storage.persist()when a local model exists -> the visibilitychange backstop. One-shot; raisesUnavailableErrorunder SSR. The handles gate onboot_state == :ready, not on their existence: mid-boot (another Task running during a boot await) and after a failed boot the database is equally unreachable -- and the raw-database paths that bypass the handles carry the gate too:Model.reset_localrequires:ready,wipeallows:readyor:failed(wiping fromon_boot_erroris the official corrupt-snapshot recovery), andpersist_snapshot-- the final persistence entry that flush and the debounce funnel through -- refuses during:booting, so a mid-boot flush cannot overwrite stored snapshots with unrestored databases. Any failure is decision 16's fail loud:boot_statebecomes:failed, the handles are torn back out, the errors hit the console andconfig.on_boot_error, nothing mounts (wired withFunicular.startin a following change), and once the hook has had its recovery chance the writer lock is released -- a failed page must not deny the writer slot to every other tab. SchemaTooNew instead completes the boot LOCKED DOWN (docs decision 7): every model-level local operation raisesSchemaTooNewError, raw SELECT export throughDB.localsurvives, writes are refused by SQLite itself.Model.reset_localarrives with it: writer-only baseline rebuild of one client-only table that lifts the lockdown once the whole declared set passes again -- and the lift is provisional: a reset that fails mid-rebuild puts SQLite's own write refusal (query_only) back up before re-raising. -
The schema boot barrier and the start gate (docs decision 19, wiring half).
Funicular.load_schemasis now a real barrier: every request settles its slot exactly once -- success, HTTP error, or a schema that arrived but cannot be applied -- so it always completes. All green boots the local database (declared models come from a new Model registry filled at subclass definition; namespace, epoch, and user-key metadata come from the include tag's HTML-escapeddata-funicular-*attributes) and only then runs the completion block; any failure never invokes the block, reports through the console andconfig.on_boot_error, and marks the boot failed.Funicular.startgates on the boot before touching the DOM: replica apps boot inside the barrier, local-only apps boot right in start, and nothing mounts on top of a failed boot. An empty schema set with a schema-less replica model declared fails the boot loud instead of running on missing tables. -
The session-epoch terminal latch (docs decision 13, client half) and HTTP's exactly-once settle. Every
Funicular::HTTPrequest now settles its callback exactly once: a rejected fetch (network failure, invalid URL) delivers a status-0 error response instead of hanging the schema barrier and every REST caller, and an exception out of the caller's own block never settles twice. When the page carried a session epoch, every response'sX-Funicular-Epochis checked -- a rotated value OR a missing header means this page belongs to a session that no longer exists: the response is discarded (the caller settles with an error, nothing is applied) and the page goes TERMINAL, irreversibly. From then on the page refuses to ISSUE requests as well -- every verb settles immediately with the same session-changed error before any fetch, since a request executed under the new session's cookies could mutate another user's data. A terminal writer steps down completely: pending persist timers are cancelled, the final persistence entry refuses forever, the writer lock frees the slot for a fresh tab -- but only after an in-flight snapshot write has landed, so a new writer can never race the old session's image -- and both database handles become a non-persistent read view. The latch is independent of durability:wipeandModel.reset_local-- the raw paths that bypass the read-only proxies -- refuse on ANY terminated page, including a volatile one, which never steps down to reader. A mismatch landing MID-BOOT (the boot suspends at the writer election and at every storage read, with nothing to tear down yet) aborts the boot through the ordinary failure funnel, releasing a writer lock the election acquired after the termination; as defense in depth, handles installed on a terminal page come up read-only.config.on_session_changeruns once (default:location.reload()). The schema barrier arms the page's epoch BEFORE its first request leaves, and the check itself latches lazily off the page otherwise -- pre-boot HTTP (an ephemeral model's REST call, a directHTTP.getat app init) is covered too, not only traffic afterDB.boot, which alone would latch too late. Pages without an epoch (no Rails integration yet) are unaffected. -
The Rails half of data isolation and the session epoch (docs decisions 12/13).
Funicular.configuregainsapplication_id(default"funicular"; give each app sharing an origin its own),user_key(a lambda receiving the controller and returning a stable identifier, nil when signed out), andanonymous_only(the explicit opt-out for apps without users) -- setting both is a configuration error raised straight from the initializer. The Railtie now stampsX-Funicular-Epochon every response (emitted lowercase, as the Rack 3 spec requires; HTTP header names are case-insensitive on the wire): the epoch lives in the Rails session PER application_id (session["funicular_epochs"]) and rotates whenever the computed user key changes, so login, logout, and direct user switches all rotate it with no application code. Rotation runs in a controller around_action (the user_key lambda needs its controller) that stamps BEFORE the action and re-stamps in its ensure with the post-action identity -- the login/logout actions flip the identity mid-request, and their own response must already carry the rotated epoch. The header itself is written by a Rack middleware sitting ABOVE ActionDispatch's exception renderer: a controller-set header dies with the controller's response when the action raises, and a header-less 500 would read as an epoch mismatch client-side, terminating a healthy page over a mere server error. Both the concern and the include-tag helper read the session throughrequest.session, never the controller/viewsessionaccessor: an application action named "session" shadows that accessor, and calling it would invoke the action itself. Session-less Rails API apps stay unbroken: a disabled session leaves the epoch feature off (no cookie identity exists to protect) instead of raising on every action.picoruby_include_tagembeds the namespace + epoch metadata as HTML-escapeddata-funicular-*attributes on the bootstrap script tag -- exactly the contractDB.read_page_metadatareads client-side -- with the user-key attribute omitted for signed-out visitors and the epoch drawn from the same session entry the response header uses; the user-key attribute and the epoch identity come from ONE resolver evaluation, so a racycurrent_usercannot embed one user's namespace with another user's epoch. Auser_keythat resolves to an empty string fails loud server-side. -
Funicular::DB.wipeand the mutation generation (docs decision 17): one call drops every table in both databases of the current namespace, deletes its two snapshot keys, rebuilds the replica DDL + fingerprint and the local migration state from scratch, and notifies watchers only once the tables are queryable again AND the stale snapshots are really gone (components re-render onto empty tables, never onto missing ones; a failing snapshot delete raises out of wipe before any watcher is told). Writer-only (ReadOnlyTabErroron a reader; fine on volatile, where there are no snapshots to delete). The wipe is safe mid-flight: it advances the mutation generation FIRST, so REST responses issued before it are discarded -- the callback gets(nil, Funicular::DB::Error)instead of resurrecting the previous session's rows -- pending persistence timers are cancelled, and an in-progress snapshot cannot overwrite the cleared state. While either database has an open transaction, wipe refuses loudly BEFORE any side effect: the rebuild would otherwise nest into (or be rolled back with) that transaction. The check cannot be raced, either: wipe never suspends its Task between the check and the end of the rebuild -- the snapshot deletes, the only awaiting operations, come last. -
The reactivity layer on top of the bus:
Component#watch(:key)binds a state key to astorage :local/.localRelation -- the block runs once, materializes intostate[:key], and re-runs (re-subscribing, so branchy blocks may switch relations) after every change event on the relation's table; anything that is not a Relation raises, pointing atModel.on_change/off_change, the public primitive for hashes, counts, and raw-SQL-derived state. Watch subscriptions die with the component even when a lifecycle hook raises. -
The guarded database handles (
mrblib/db.rb,Funicular::DB::GuardedDatabase/GuardedStatement/GuardedResultSet): the proxiesFunicular::DB.local/.replicawill hand out instead of raw connections. The allowlist is closed -- persist/close/serialize/deserialize/backup do not exist in any state,transactionyields the proxy itself, andqueryreturns a wrapped result set. Read-only handles enforce at EVERY execution entry (execute, step, ResultSet next/reset) viaStatement#readonly?(a write prepared while writable is still refused after the handle went read-only, one-way), raisingFunicular::DB::ReadOnlyTabError; ATTACH/DETACH andPRAGMA query_onlyare rejected in every state, comment prefixes included, while read pragmas stay available. -
The namespace identity (
mrblib/db.rb): a typed, versioned tuple (["v1", app, "anonymous"]/["v1", app, "user", key]) encoded as canonical JSON, which every durable name -- the two snapshot keys and the Web Lock name -- derives from. Structure, not delimiters, separates the fields, so a user_key of "anonymous" or one containing separators cannot collide.resolve_namespaceenforces the declaration rules client-side (Funicular::DB::ConfigError): user_key and anonymous_only are mutually exclusive, and every opted-in application requires a user_key unless anonymous_only explicitly accepts one shared anonymous namespace. -
REST is wired to the local database layer: response values decode through the shared codec when instances initialize and when
updateapplies the server row (ISO 8601 strings becomeTime, 1/0 become booleans --Post.allandPost.local.findnow return the same Ruby types), and every successful REST call mirrors its result into the replica through the single apply entry point BEFORE user callbacks run (all/find/createupsert,updateupserts the applied server row,destroydeletes). Write-through stays inert untilFunicular::DB.bootinstalls the replica handle, so REST keeps working standalone. -
The replica-table plumbing (
mrblib/db.rb): CREATE TABLE derived from the server schema (id type follows the server -- INTEGER or TEXT; a schema without id raises pointing atstorage :ephemeral; binary attributes never reach the replica), the canonical-JSON schema fingerprint stored infunicular_meta(string equality; a mismatch drops and recreates ALL replica tables empty, refilled by the app's next explicit fetch), and the single write-through entry pointsreplica_upsert(whole-row INSERT OR REPLACE through the codec) andreplica_delete(RETURNING-based), both firing the model's change hook. Boot wiring and the REST call sites arrive next. -
Local CRUD and the bare-class alias on
storage :localmodels: synchronous, validatedcreate(id from the inserted row; omitted attributes take the SQL DEFAULT while an explicit nil binds NULL; the row is read back, so defaults and codec normalization land in the instance; autocreated_at/updated_at),#update(true/false; an update with no actual changes is a no-op that does not touchupdated_at),#destroy,#reload,#new_record?;Draft.allis the whole-table Relation (blocks and params raise -- there is no REST side), andwhere/order/limit/offset/count/first/exists?/find_by/delete_allhang off the bare class, which on other storage kinds points you at.local. All local writes fire thelocal_table_changedhook and let SQLite constraint violations escape asSQLite3::Exception.Model.createnow also accepts bare keywords (Draft.create(title: "x")) on every storage kind.
- Every
Funicular::ModelREST callback is now uniformly(result, error): on successresultis the payload (all-> array,find/create-> instance,update-> the applied instance,destroy->true) anderroris nil; on failureresultis nil.updateanddestroyused to yield boolean-first(true/false, data_or_error); callsites reading the first argument as a boolean must be updated.updatewith nothing to send (no changes, or binary-only changes) now reports a successful no-op instead of silently not calling the block.
-
Model#initializeuses key-presence lookups instead of||, so a string-keyedfalse(boolean columns) no longer collapses to nil. -
Dev-mode SSR reloads edited component sources instead of caching them per process (the railtie enables it in development): SSR markup no longer goes stale behind the middleware's recompiled app.mrb until a server restart. Concurrent renders serialize the reload, and a file vanishing mid-edit does not break the mtime check.
-
Event-handler and HTTP-callback exceptions name the component and handler on the console before re-raising, instead of an anonymous "Callback " line -- or, for HTTP callbacks, nothing at all.
-
Schema.buildskips validator introspection forreadonly: trueattributes: server-managed columns no longer fail client-side validation against values the client never edits. -
Schema::RegexpTranslatorunescapes Ruby's\#identity escape, which survives inRegexp#sourcebut is rejected by the JS RegExp engine under theuflag (URI::MailTo::EMAIL_REGEXPis the common casualty: one such validator used to fail the whole client boot). -
Schema.serializenow carriesallow_nil/allow_blankthrough to the client -- including kinds that serialize to a baretrue, such aspresenceand unconstrainednumericality, which upgrade to a Hash -- so a validator on an optional attribute no longer rejects the nil or blank value the server accepts. -
A schema
formatregex the client runtime cannot compile downgrades to a console warning and drops that one validator instead of failing the whole schema load. -
FileUpload.upload_with_formdataattaches the CSRF token from the page's meta tag (Rails forgery protection rejected every upload) and accepts amethod:keyword instead of hard-coding PATCH.
- The IndexedDB-backed HTTP response cache (
Funicular::HTTPcache:option,cache_purge,cache_clear). It was dead code -- no caller anywhere passedcache:-- and the local database layer is this release's answer to caching. Structured data belongs in replica tables, not keyed response bodies.
- 0.4.0 bareword component DSL:
render(zero-arity) runs withselfas the component, so HTML tags,component,form_for,link_to,button_to,suspense,state,props,styles,resources, androutesare all called bareword, without the 0.3.0h.receiver. - DSL collision detection: tag and helper names are reserved inside
component classes. Defining one raises
Funicular::DSLCollisionErrorat class-definition time (method_added) or at first mount (validate_dsl_conflicts!, coveringattr_*on mruby and included modules).allow_dsl_override :nameopts out per class; the shadowed element stays reachable viatag(:name, ...). - Bareword style definitions: the class-level
styles do ... endblock runs on aBasicObjectcleanroom builder, so any name (includingdisplay,hash, ...) defines a style identically on mruby and CRuby. The explicitstyles { |css| css.define(...) }form remains for computed values. - Generated style accessors: each declared style name becomes a real
method on a per-component accessor, e.g.
styles.button(:disabled); thestyles[:name, variant]form is kept.
- 0.4.0 is a breaking DSL change against 0.3.0. Components written for
0.3.0 migrate mechanically: delete the
render(h)parameter, drop theh.receivers, convertcss.define :name, "..."to barewordname "...", andh.styles[:name, variant]tostyles.name(variant). pinside a component builds a<p>element. Debug withputs x.inspect; a non-Hash argument to any tag raisesArgumentErrorwith a hint.- A local variable named after a tag shadows the zero-paren call form
(plain Ruby scoping); write
option()or rename the local. - Tag and helper names (RESERVED_DSL) can no longer be defined as
component methods without
allow_dsl_override. - Style lookups of unknown names raise (
NoMethodErrorforstyles.typo,ArgumentErrorforstyles[:typo]) instead of silently returning an empty class string. Style definition values are validated (String / Hash / keyword options; unknown option keys raise). - Tag helpers called while the component is not rendering raise
Funicular::RenderContextErrorinstead of being silently dropped. ErrorBoundaryfallback:/error:procs keep an explicit view context (->(h, error) { h.div { ... } }): they are created in the parent's scope but run during the boundary's render, so barewords cannot work there by design.Component#render_suspenseno longer takes a view context; suspensefallback:/error:/content procs run bareword in their own component (fallback: -> { div { "Loading" } }).
- Requires picoruby-wasm with
JS::Object < BasicObject(picoruby 9e69333f): Kernel names (hash,send,open, ...) no longer shadow JS property access, and unknown?/!methods on JS values raise.
- 0.3.0 rendering architecture:
render(h)now receives aViewContextfacade for elements, components, forms, styles, resources, and routes. - Per-app
Runtimecontext for route helpers and renderer/serializer propagation, enabling isolated route helper sets across multiple apps.
- 0.3.0 is a deliberate breaking DSL redesign. Existing Funicular components written for 0.2.x require source changes.
- Component render methods must now accept a view context:
def render(h). The former implicit component-level DSL methods for HTML tags,component,form_for,link_to,button_to,suspense, styles, resources, and route helpers have been removed. - HTML and framework helpers are now called through
h, for exampleh.div,h.component(...),h.form_for(...),h.link_to(...),h.suspense(...),h.styles[...],h.resources[...], andh.routes. - Component state reads are explicit: use
state[:key],state.fetch(:key), orh.state[:key]. The oldstate.key_namemethod-style access has been removed. - Style definitions are explicit: use
styles { |css| css.define(...) }. The old dynamic style definition DSL has been removed. - Component children are stored as
VDOM::Component#children. The oldchildren_blockprop path has been removed and no compatibility shim is provided. - Route helpers are scoped by
Funicular::Runtime; globalFunicular::RouteHelpersinjection has been removed. Code that depends on route helpers should useh.routes. FormBuilder,ErrorBoundary, SSR, hydration, renderer, patcher, and HTML serialization now operate through the sameViewContext/Runtimearchitecture.
- Since mruby-compiler-prism, which used to be mruby-compiler2 producing picorbc, has become the default compiler for mruby, we changed the name from picorbc to mrbc.
- Harden VDOM rendering against HTML and script injection in both SSR and
browser rendering: validate tag and attribute names, reject
scriptelements, and consistently block case-obfuscated event handlers,srcdoc, and unsafe URL schemes including control-character variants.
- Funicular::Component: Add
namefield to form to find state changed.
- Funicular::Store DSL: Declarative client-side stores backed by
IndexedDB. Subclass
Funicular::Store::Singleton(one value per scope) orFunicular::Store::Collection(ordered list per scope) and use class-level DSL (database,scope,limit,key,expires_in,cleared_on,subscribes_to) to wire up persistence, TTL, event-based clearing, and ActionCable integration. Funicular::Store.dispatch(:event)for coordinated store clearing (e.g., logout wipes all stores registered withcleared_on :logout)subscribes_toDSL for embedding Cable message handling directly in store classes; scopes gainsubscribe!/unsubscribe!/subscribed?- Lazy KVS initialization: stores open IndexedDB on first access, removing
the need for explicit
init!calls in application initializers Funicular::Store::Scope#on_change/off_changefor reactive UI updates when store data changes
Funicular::Cable::Consumernow automatically resubscribes all active subscriptions after WebSocket reconnect (resubscribe_all)
- Consolidated with picoruby-funicular: merged the full PicoRuby frontend framework into this gem, including Component, Cable, VDOM, Router, FormBuilder, Model, HTTP, FileUpload, ErrorBoundary, Styles, Differ, Patcher, Debug, and EnvironmentInquirer, along with RBS signatures and comprehensive test suite
- Bundle PicoRuby.wasm and picorbc WASM artifacts into the gem via a
rake copy_wasmtask; artifacts are vendored at build time so no runtime npm lookup is required Funicular::Configurationwith per-environment PicoRuby.wasm source selection (:local_debug,:local_dist,:cdn) and optionalcdn_versionoverridepicoruby_include_tagview helper (auto-registered via Railtie) that serves the appropriate PicoRuby.wasm build per environmentfunicular:install:wasmrake sub-task to copy dist/debug WASM builds intopublic/picoruby/- Rails Asset Pipeline integration: Rack middleware, compiler, and
funicular:compile/funicular:installrake tasks funicular routesCLI command andFunicular::RouteParserto inspect Rails routes from the command line- Component Debug Highlighter: CSS/JS assets (
funicular_debug.css,funicular_debug.js) that highlight the selected component in the browser ENV['FUNICULAR_ENV']is now set fromRails.envin generatedapplication.rb
- picorbc is now resolved from a vendored WASM artifact; removed
npm-based picorbc lookup and all
PICORBC_VERSIONenvironment variable logic - Upgraded picorbc to the latest version
- Switched test framework from test/unit to minitest
- Asset pipeline: middleware now detects whether
app.mrbhas actually changed before recompiling, preventing unnecessary rebuilds - XSS vulnerabilities in VDOM attribute handling: expanded
URL_ATTRIBUTESconstant, applied case-insensitivejavascript:URI blocking, and added the same URL validation toPatcher#update_propsandPatcher#create_element - XSS vulnerability in Debug module: replaced manual JSON string
concatenation with
JSON.generateto eliminate escaping gaps funicular:compilerake taskfunicular:installrake task- Rack middleware
- RBS type signatures
- Debugger Chrome extension (
debugger/directory) .ruby-versionfile
- Initial release