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
data/docs/architecture.md CHANGED
@@ -38,10 +38,12 @@ stay free of browser-only calls on any server code path
38
38
  | `patcher.rb` | `Patcher.apply(dom, patches)` -- apply patches to the real DOM |
39
39
  | `html_serializer.rb` | `VDOM::HTMLSerializer` -- VDOM to HTML string (used by SSR) |
40
40
  | `router.rb` | Client-side router, route DSL, per-runtime route helper object, History API |
41
- | `model.rb` | Object-REST Mapper (`all`/`find`/`create`/`update`/`destroy`) |
42
- | `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
41
+ | `model.rb` | Object-REST Mapper (`all`/`find`/`create`/`update`/`destroy`) + local query API (`storage`/`refresh`, associations, `migrate` blocks) |
42
+ | `db.rb` | `Funicular::DB`: local SQLite databases, DDL derivation, snapshot persistence, change events, `wipe` |
43
+ | `relation.rb` | Lazy chainable Relation and SQL builder for local queries |
44
+ | `http.rb` | Low-level fetch wrapper, CSRF |
43
45
  | `cable.rb` | ActionCable-compatible consumer/subscription client |
44
- | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
46
+ | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores (superseded by the local database; see below) |
45
47
  | `form_builder.rb` | `form_for` field helpers with inline error rendering |
46
48
  | `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
47
49
  | `styles.rb` | CSS-in-Ruby bareword `styles do ... end` builder and generated `styles.name` accessors |
@@ -84,6 +86,182 @@ Component children are ordinary VDOM children stored on
84
86
  `VDOM::Component#children`; there is no delayed `children_block` prop. This keeps
85
87
  SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
86
88
 
89
+ ## Local database (sqlite3)
90
+
91
+ `Funicular::Model` is backed by an in-browser SQLite database
92
+ (picoruby-sqlite3 on wasm: `:memory:` database + IndexedDB snapshot
93
+ persistence). The user-facing contract is documented in
94
+ [local_database.md](local_database.md); the contributor-relevant invariants
95
+ are:
96
+
97
+ - The entire subsystem is optional and defaults off. Rails emits an explicit
98
+ page opt-in only for `config.local_database = true`; without it schema and
99
+ REST traffic continue normally, while DB boot, SQLite, IndexedDB, Web Locks,
100
+ replica write-through, and session-epoch handling do not run. Disabled
101
+ `storage :local` declarations fail at the pre-DOM start gate, and runtime
102
+ local APIs raise `UnavailableError`. Disabling does not delete snapshots.
103
+
104
+ - Two databases split by durability class: `funicular_replica`
105
+ (server-recoverable, dropped and rebuilt on schema mismatch) and
106
+ `funicular_local` (client-only data, evolved via numbered `migrate` blocks
107
+ with per-table versions in a meta table; never dropped in production --
108
+ dev auto-resets on migration failure, `reset: true` baselines and
109
+ `Model.reset_local` are the explicit reset paths). Both auto-persist,
110
+ in the `persistent_writer` state only, via debounce plus a
111
+ `visibilitychange` backstop.
112
+ - One apply entry point: every FRAMEWORK-CONTROLLED replica write
113
+ (fetch-through, write-through, and, in the future, Cable-pushed
114
+ replication) funnels through the same upsert/delete path in
115
+ `Funicular::DB`, which is also where per-table change events fire. The
116
+ sanctioned exception is app-level raw SQL through the guarded handles,
117
+ whose contract is to call `notify_changed` afterwards. Relation#delete_all
118
+ exists only for `storage :local`; on replica Relations it raises
119
+ ReplicaWriteError even on the writer tab.
120
+ - The source-of-truth contract on `Funicular::Model`: the bare class targets
121
+ the model's source of truth -- REST verbs with optional callbacks of ONE
122
+ shape, `(result, error)`, for replica/ephemeral models (BREAKING vs <=0.4:
123
+ update/destroy were boolean-first), the local table for `storage :local`
124
+ models (there the `.local` prefix is an optional alias). `.local` is the
125
+ explicitly-marked local/cache view: it returns immediate values and raises
126
+ on genuine bugs; on ephemeral models it raises
127
+ `Funicular::DB::NoTableError`.
128
+ - Boot barrier: all schemas are collected before replica DDL/fingerprint
129
+ work runs (exactly once per boot); any schema failure fails startup with
130
+ aggregated errors. The fingerprint covers DDL-affecting data only and IS
131
+ the canonical schema JSON, stored in a replica metadata table and
132
+ compared by string equality (no digest dependency; never user_version).
133
+ - Isolation: namespace identity is a typed, versioned tuple
134
+ (["v1", app_id, "anonymous"] / ["v1", app_id, "user", key]) encoded as
135
+ canonical JSON -- never naive concatenation (collision-free even for a
136
+ literal "anonymous" user key or delimiter-containing values); the same
137
+ encoded identity is used for snapshot keys, the Web Lock name, the
138
+ previous-identity value in the Rails session, and epoch rotation.
139
+ (Configuration contract for user_key/anonymous_only: see the dedicated
140
+ bullet below.) When the subsystem is enabled, a separate SESSION EPOCH is
141
+ managed by the Railtie with no app code: kept in the Rails session, rotated (SecureRandom)
142
+ whenever the computed user_key changes, stamped on all REST/schema
143
+ responses (X-Funicular-Epoch). A mismatching OR MISSING epoch on an
144
+ apply-path response moves the page to a TERMINAL invalid-session state:
145
+ the response is discarded and replica applies, local writes, raw writes,
146
+ and persistence are refused for the life of the page (a non-reloading
147
+ on_session_change hook cannot re-enable them; default hook reloads). The
148
+ terminal flag is an irreversible latch independent of durability state;
149
+ a terminal WRITER steps down -- debounce cancelled, in-flight persist
150
+ serialized, lock-holding promise resolved so the lock releases,
151
+ connections kept only as a non-persistent read view.
152
+ - Durability is a three-state machine per page: persistent_writer (holds
153
+ the Web Lock; restores, persists, writes), persistent_reader (replica
154
+ fully functional incl. in-memory revalidation writes, never persisted;
155
+ LOCAL connection PRAGMA query_only=ON; local writes and
156
+ flush/wipe/reset_local raise ReadOnlyTabError; persist/close are not
157
+ on the proxy surface at all, on any tab), and
158
+ volatile (everything works including local writes, nothing persists;
159
+ entered when Web Locks or IndexedDB are unavailable; named distinctly
160
+ from the `storage :ephemeral` model declaration). Lock protocol: boot
161
+ decides instantly via `ifAvailable: true`; a reader stays reader for the
162
+ life of the page (no promotion in v1 -- reload to write); the writer
163
+ parks the lock on a promise resolved at teardown. DB handles
164
+ are guarded proxies with a closed allowlist, never raw SQLite3::Database:
165
+ proxy `transaction` yields the proxy (the gem's own transaction yields
166
+ the raw db), `prepare` returns a guarded statement, batch/deserialize/
167
+ commit/rollback are classified; pending notify_changed state is tied to
168
+ raw-transaction commit/rollback. Read-only states are enforced per
169
+ STATEMENT at execution time via sqlite3_stmt_readonly
170
+ (Statement#readonly?, new sqlite3 API): write statements -> framework
171
+ exception; PRAGMA query_only/ATTACH/DETACH rejected separately (SQLite
172
+ classifies them read-only; query_only alone is not a guarantee -- it can
173
+ be switched OFF); execute_batch refused outright in read-only states;
174
+ EVERY execution entry point re-checks the current latch/state when run
175
+ (execute, step, ResultSet#next, get_first_* alike).
176
+ - `wipe` (writer-only) advances the MUTATION GENERATION (deliberately not
177
+ called "epoch" -- the session epoch is a different mechanism): stale
178
+ in-flight applies are rejected, debounce timers cancelled, watchers
179
+ notified only after DBs are queryable again. If ANY local table's stored
180
+ migration version exceeds the declared maximum, the WHOLE local DB fails
181
+ loud: every local-model operation (read or write, any table) raises
182
+ SchemaTooNewError and the DB sits at query_only=ON; raw SELECT export and
183
+ reset_local (which internally lifts query_only for the rebuild; still
184
+ ReadOnlyTabError on non-writer tabs) are the only doors. No per-table
185
+ nuance in v1.
186
+ - When opted in, boot is one state machine (`Funicular::DB.boot`, driven by
187
+ Funicular.start, independent of load_schemas usage): declarations ->
188
+ namespace+epoch -> writer election -> local restore+migrations -> replica
189
+ restore+fingerprint (after ALL schemas collected) -> components mount.
190
+ Schema failure: completion block not invoked, console.error always,
191
+ on_boot_error(errors) when set. The HTTP layer settles every schema
192
+ request exactly once (success / HTTP error / parse error / Promise
193
+ rejection), so the barrier cannot hang; an empty schema set is valid only
194
+ when no replica models are declared.
195
+ - picoruby-indexeddb error classification cannot ride the generic JS
196
+ Promise bridge (it carries only error.message; JS::Promise#await raises a
197
+ string): the gem's own EM_JS promises resolve a TAGGED result -- success
198
+ as { ok:, value: }, failure as { ok:, name:, message: } -- converted to
199
+ values/typed Ruby exceptions; the same shape applies to the request and
200
+ transaction helpers, not just open. Only listed availability errors
201
+ (SecurityError, InvalidStateError, missing global) fall back to the
202
+ in-memory store (-> volatile state); quota and data errors surface;
203
+ onblocked is not availability failure -- it waits with a fixed timeout
204
+ (default 5000ms), raises a typed BlockedError on expiry, and a late open
205
+ success after the timeout closes that connection immediately; open cleans
206
+ up its callback registry in ensure.
207
+ - picoruby-sqlite3 gains official primitives instead of Funicular poking
208
+ internals: open a memory DB without snapshot binding (no restore/no
209
+ persist -- plain `close` on an unbound DB persists nothing, so no
210
+ `close(persist: false)` variant is needed) and `Statement#readonly?`. (No
211
+ named-snapshot-deletion API: it would target the gem's built-in store,
212
+ which Funicular does not use -- `wipe` deletes the two namespaced keys
213
+ directly from Funicular's own KVS.) Snapshots are stored Base64-encoded
214
+ (binary Strings do not survive the JS bridge). The proxy exposes NO
215
+ `persist`/`close` on any tab: the framework's DBs are unbound memory DBs
216
+ (SQLite-level persist has no valid target; close would destroy a
217
+ framework-owned connection). Snapshot I/O itself is OWNED BY FUNICULAR:
218
+ `Funicular::DB` opens its IndexedDB store with `fallback: false` and does
219
+ its own serialize/deserialize round trips -- the sqlite3 gem's built-in
220
+ STORE_NAME persistence (whose KVS defaults to fallback: true and would
221
+ hide unavailability) is not used by Funicular. Storage failure at boot,
222
+ v1: availability errors on open -> volatile (backend absent by design);
223
+ ANY other storage failure -- open errors
224
+ (QuotaExceeded/Unknown/Version/Blocked-timeout) or snapshot GET errors
225
+ (quota/data) -- FAILS THE BOOT: components do not mount, console.error +
226
+ on_boot_error. Recovery: corrupt snapshot (GET failure, handle exists) ->
227
+ the hook may call wipe then reload; open failure -> fix browser state,
228
+ reload. No latches, no partial boot, no in-page recovery (deferred).
229
+ - watch, v1: the block must return a Relation (else a helpful raise); the
230
+ framework materializes it and subscribes to that Relation's table,
231
+ re-subscribing each evaluation. Hashes/scalars/raw SQL: use Model.on_change
232
+ + patch. (The dependency collector, tables: option, and refresh :auto's
233
+ barrier-time all-endpoint validation are deferred with :auto itself.)
234
+ - Every opted-in application requires `user_key`, or `anonymous_only = true`
235
+ as the explicit choice for an auth-less app (both set = config error), even
236
+ when it has only replica models. Rails validates after initialization and
237
+ before helper output; DB.boot validates the emitted contract defensively.
238
+ The opt-in flag plus namespace and epoch metadata are emitted by
239
+ `picoruby_include_tag` as HTML-escaped data attributes (no new helper;
240
+ CSR-only apps covered). No metadata means disabled, never an implicit
241
+ anonymous `"funicular"` namespace.
242
+ Session epoch state is stored PER application_id
243
+ (session[:funicular_epochs][app_id] = { identity:, epoch: }) so multiple
244
+ Funicular apps sharing one Rails session cannot rotate each other's
245
+ epochs.
246
+ - notify_changed takes a model class or (role, table) pair -- change
247
+ identity is [database_role, table_name]; inside a raw transaction the
248
+ event and persist scheduling defer to commit and vanish on rollback.
249
+ - Event bus: change events fire post-commit, coalesced to one per table per
250
+ transaction; watcher delivery is queued (never nested inside a component
251
+ update) and subscriber exceptions are isolated.
252
+ - SSR: all SQLite3 access is deferred inside methods so class bodies still
253
+ evaluate under CRuby; materializing a local query during SSR raises
254
+ `Funicular::DB::UnavailableError` (fail loud, never an empty result).
255
+
256
+ The Store layer (`store.rb`, `store_singleton.rb`, `store_collection.rb`) is
257
+ superseded by this: every Store feature has a local-database counterpart
258
+ (scopes -> tables/where, `expires_in` -> `refresh :auto`, `on_change` ->
259
+ table change events + `watch`, `subscribes_to` -> future `refresh :live`,
260
+ `cleared_on`/`dispatch` -> `Funicular::DB.wipe`). Store stays in the tree
261
+ untouched for now -- its `subscribes_to` implementation is the design
262
+ reference for `refresh :live` -- but nothing new builds on it, and it will be
263
+ deprecated and removed once `refresh :live` ships.
264
+
87
265
  ## `lib/` Rails integration
88
266
 
89
267
  - `compiler.rb` -- runs the vendored `mrbc` (WebAssembly, via Node.js) to