funicular 0.3.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 +486 -1
- data/demo/local_notes.html +207 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +227 -12
- 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 +58 -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 +655 -574
- 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 +800 -530
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- 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 -6909
- 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/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- 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 +264 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- 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/sig_tags_test.rb +30 -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/minitest/view_context_test.rb +15 -15
- data/mrbgem.rake +2 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +226 -24
- data/mrblib/db.rb +3116 -0
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/form_builder.rb +10 -10
- 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 +122 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +25 -4
- data/sig/db.rbs +328 -0
- data/sig/error_boundary.rbs +4 -4
- 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 +19 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +23 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
# Local Database
|
|
2
|
+
|
|
3
|
+
Funicular can provide a real relational database inside the browser: SQLite,
|
|
4
|
+
compiled to WebAssembly, queried from Ruby with an ActiveRecord-flavored API.
|
|
5
|
+
The subsystem is disabled by default; an application that does not opt in
|
|
6
|
+
remains REST-only and does not open SQLite, IndexedDB, or Web Locks.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Post.local.where(published: true).order(created_at: :desc).limit(10).each do |post|
|
|
10
|
+
# instant, synchronous, no spinner -- this never touches the network
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This document is the complete guide to the local database layer: what it is
|
|
15
|
+
for, how to declare models, how to query, how data flows in and out, and what
|
|
16
|
+
its durability guarantees are.
|
|
17
|
+
|
|
18
|
+
## Mental model
|
|
19
|
+
|
|
20
|
+
**The Rails server is the source of truth. The local database is a structured,
|
|
21
|
+
queryable replica plus a home for client-only data.**
|
|
22
|
+
|
|
23
|
+
Every piece of data in the local database belongs to one of two categories,
|
|
24
|
+
and the difference matters for everything else in this document:
|
|
25
|
+
|
|
26
|
+
- **Replica data** is a local copy of rows that live in your Rails database.
|
|
27
|
+
It arrives via the REST API you already have. Losing it costs nothing but a
|
|
28
|
+
refetch. It exists so that reads are instant and relational.
|
|
29
|
+
- **Client-only data** exists nowhere but this browser: drafts, local
|
|
30
|
+
preferences, unsent form state. Losing it means losing user work, so it is
|
|
31
|
+
persisted more aggressively and never dropped because of anything the
|
|
32
|
+
server or the replica schema does. The only paths that discard it are the
|
|
33
|
+
explicit resets (development auto-reset, a `reset: true` baseline,
|
|
34
|
+
`reset_local`, `wipe`).
|
|
35
|
+
|
|
36
|
+
Physically these are two separate SQLite databases (`funicular_replica` and
|
|
37
|
+
`funicular_local`), each snapshotted independently to IndexedDB. You never
|
|
38
|
+
open or manage them yourself; model declarations decide where a model's table
|
|
39
|
+
lives.
|
|
40
|
+
|
|
41
|
+
### The source-of-truth contract
|
|
42
|
+
|
|
43
|
+
One lexical rule runs through the whole `Funicular::Model` API:
|
|
44
|
+
|
|
45
|
+
- **The bare class talks to the model's source of truth.** For replica and
|
|
46
|
+
ephemeral models the truth is the Rails server, so the bare class speaks
|
|
47
|
+
REST: `Post.all { }`, `Post.find(id) { }`, `Post.create(attrs) { }` --
|
|
48
|
+
all network, all asynchronous, all reporting through an optional callback
|
|
49
|
+
block with ONE shape: `(result, error)`. On success `result` is the
|
|
50
|
+
payload and `error` is nil; on failure `result` is nil. Fire-and-forget
|
|
51
|
+
(no block) is legal.
|
|
52
|
+
|
|
53
|
+
> **Breaking change vs Funicular <= 0.4**: `update` and `destroy` used to
|
|
54
|
+
> yield `(true/false, data_or_error)`. Every REST callback is now uniformly
|
|
55
|
+
> `(result, error)`: `all` yields the model array, `find`/`create` the
|
|
56
|
+
> instance, `update` the updated instance (as applied to the replica -- see
|
|
57
|
+
> write-through), `destroy` yields `true`. Existing callsites that read the
|
|
58
|
+
> first argument as a boolean must be updated.
|
|
59
|
+
- **`.local` is the local database view.** `Post.local.where(...)` reads the
|
|
60
|
+
replica: instant and synchronous, but possibly stale -- writing `.local`
|
|
61
|
+
is how you acknowledge "this may be a cache". Local reads return values;
|
|
62
|
+
genuine bugs raise exceptions, with no error-handling ceremony.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Post.all do |posts, error| # network: block, (result, error)
|
|
66
|
+
...
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
posts = Post.local.where(published: true) # local: immediate return value
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For `storage :local` models the local database IS the source of truth, so
|
|
73
|
+
the bare class and `.local` are interchangeable: `Draft.where(...)` is
|
|
74
|
+
`Draft.local.where(...)`; the prefix is optional there.
|
|
75
|
+
|
|
76
|
+
## Quick start
|
|
77
|
+
|
|
78
|
+
First, enable the subsystem and declare how browser storage is isolated:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# config/initializers/funicular.rb (Rails)
|
|
82
|
+
Funicular.configure do |config|
|
|
83
|
+
config.local_database = true
|
|
84
|
+
config.user_key = ->(controller) {
|
|
85
|
+
controller.current_user&.storage_key
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An application with no user accounts can deliberately share one anonymous
|
|
91
|
+
namespace instead:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Funicular.configure do |config|
|
|
95
|
+
config.local_database = true
|
|
96
|
+
config.anonymous_only = true
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Enabling the feature without exactly one of these identity declarations fails
|
|
101
|
+
during Rails startup. Conversely, adding `.local` calls while leaving the
|
|
102
|
+
feature disabled raises a clear runtime error, and declaring `storage :local`
|
|
103
|
+
while disabled prevents `Funicular.start` from mounting the application.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# app/funicular/models/post.rb
|
|
107
|
+
class Post < Funicular::Model
|
|
108
|
+
belongs_to :user
|
|
109
|
+
has_many :comments
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# app/funicular/models/draft.rb
|
|
113
|
+
class Draft < Funicular::Model
|
|
114
|
+
storage :local do
|
|
115
|
+
migrate 1 do |t|
|
|
116
|
+
t.string :title
|
|
117
|
+
t.text :body
|
|
118
|
+
t.integer :post_id
|
|
119
|
+
t.timestamps
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# app/funicular/components/blog_index_component.rb
|
|
125
|
+
class BlogIndexComponent < Funicular::Component
|
|
126
|
+
def initialize_state
|
|
127
|
+
{ posts: [] }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def component_mounted
|
|
131
|
+
watch(:posts) { Post.local.where(published: true).order(created_at: :desc) }
|
|
132
|
+
Post.all { |_posts, error| patch(error: error) if error }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render
|
|
136
|
+
div do
|
|
137
|
+
state[:posts].each { |post| component PostRow, post: post }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
What happens here:
|
|
144
|
+
|
|
145
|
+
1. `Post` is an ordinary schema-loaded model. When the local database is
|
|
146
|
+
enabled, the default `storage :replica` gives it a table auto-created from
|
|
147
|
+
the schema your Rails server already delivers.
|
|
148
|
+
2. `Post.all { ... }` fetches from the REST endpoint as it always has; the
|
|
149
|
+
framework additionally upserts every fetched row into the replica table
|
|
150
|
+
(fetch-through).
|
|
151
|
+
3. `watch(:posts)` binds `state[:posts]` to a local query. Whenever the
|
|
152
|
+
`posts` table changes -- because a fetch landed, or a write went through --
|
|
153
|
+
the block re-runs and the component re-renders. You never wire this up
|
|
154
|
+
manually.
|
|
155
|
+
|
|
156
|
+
## Declaring models
|
|
157
|
+
|
|
158
|
+
Two orthogonal declarations control a model's relationship with an enabled
|
|
159
|
+
local database. Both have defaults chosen so that the common opted-in case
|
|
160
|
+
needs no declaration at all.
|
|
161
|
+
|
|
162
|
+
### `storage` -- where the model's data lives
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
storage :replica # default; you do not write this
|
|
166
|
+
storage :ephemeral
|
|
167
|
+
storage :local
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- **`:replica`** (default). The model is backed by a local table derived from
|
|
171
|
+
the server schema (`Funicular.load_schemas`). REST results flow into it
|
|
172
|
+
automatically. The table is dropped and rebuilt whenever the server schema
|
|
173
|
+
changes -- replicas are disposable by design.
|
|
174
|
+
- **`:ephemeral`**. No local table, nothing written to disk. The model
|
|
175
|
+
behaves exactly like a classic Funicular model: REST only. Use this for
|
|
176
|
+
sensitive models whose data must not rest in browser storage
|
|
177
|
+
(authentication/session models are the canonical case). `.local` raises
|
|
178
|
+
`Funicular::DB::NoTableError`.
|
|
179
|
+
- **`:local`**. Client-only table. No server schema, no REST integration;
|
|
180
|
+
the table shape is declared with `migrate` blocks on the `storage`
|
|
181
|
+
declaration itself (below). Writes are local and synchronous. The local
|
|
182
|
+
table is the source of truth, so the `.local` prefix is optional:
|
|
183
|
+
`Draft.where(...)` == `Draft.local.where(...)`. `storage :local` is the
|
|
184
|
+
only variant that takes a block; passing one to `:replica` or `:ephemeral`
|
|
185
|
+
raises.
|
|
186
|
+
|
|
187
|
+
#### How the databases boot
|
|
188
|
+
|
|
189
|
+
When `config.local_database = true`, database startup is one state machine
|
|
190
|
+
(`Funicular::DB.boot`), driven by `Funicular.start`, and it runs whether or not
|
|
191
|
+
the app uses server schemas at all -- an app with only `storage :local` models
|
|
192
|
+
and no `Funicular.load_schemas` call still gets namespace resolution, writer
|
|
193
|
+
election, snapshot restore, and local migrations. The order is fixed:
|
|
194
|
+
|
|
195
|
+
1. Model class bodies only record declarations; nothing touches SQLite.
|
|
196
|
+
2. The application/user namespace and session epoch are resolved from the
|
|
197
|
+
page.
|
|
198
|
+
3. Writer election runs (Web Lock).
|
|
199
|
+
4. The local database is restored and its migrations applied.
|
|
200
|
+
5. Once ALL requested server schemas have arrived, the replica database is
|
|
201
|
+
restored and its fingerprint validated -- DDL derivation and comparison
|
|
202
|
+
run exactly once per boot, over the complete, canonically-ordered set.
|
|
203
|
+
6. Components mount/hydrate only after the databases they can reach are
|
|
204
|
+
queryable; a `watch` can never observe a half-booted database.
|
|
205
|
+
|
|
206
|
+
With the local database disabled, schema requests and REST CRUD still run,
|
|
207
|
+
but successful schema loading proceeds directly to component startup without
|
|
208
|
+
waiting for a database. If any schema request fails, startup fails loudly and
|
|
209
|
+
precisely in either mode: the
|
|
210
|
+
`load_schemas` completion block is NOT invoked (so the app's
|
|
211
|
+
`Funicular.start` call inside it never runs), the replica database is not
|
|
212
|
+
initialized (a partial replica would be worse than none), and the failure
|
|
213
|
+
is always written to the console -- `config.on_boot_error` additionally
|
|
214
|
+
receives the aggregated errors naming each failed model, but leaving it
|
|
215
|
+
unset never means silence.
|
|
216
|
+
|
|
217
|
+
"Fails" is airtight by construction: the HTTP layer converts every outcome
|
|
218
|
+
-- success, HTTP error status, parse error, and fetch Promise rejection
|
|
219
|
+
(network down, CORS, aborted) -- into exactly one callback invocation per
|
|
220
|
+
request, so the barrier always settles; it cannot hang waiting for a
|
|
221
|
+
request whose Promise rejected. When the database is enabled, an EMPTY schema
|
|
222
|
+
set is only a valid boot when no replica models are declared -- replica models
|
|
223
|
+
with zero loaded schemas would mean mounting components over nonexistent
|
|
224
|
+
tables, so that is a boot failure too.
|
|
225
|
+
|
|
226
|
+
The schema fingerprint covers only what affects SQLite DDL -- table names,
|
|
227
|
+
column names and types, and the id type. Endpoint or validation changes on
|
|
228
|
+
the server do NOT rebuild the replica. The fingerprint is the canonical
|
|
229
|
+
schema JSON itself, stored in a metadata table inside the replica database
|
|
230
|
+
and compared by plain string equality -- no hash algorithm, no extra
|
|
231
|
+
dependency, no collision to reason about (and no `PRAGMA user_version`,
|
|
232
|
+
which is a 32-bit integer and could not hold it anyway).
|
|
233
|
+
|
|
234
|
+
When the fingerprint mismatches, tables are dropped and recreated -- and the
|
|
235
|
+
rebuilt replica starts EMPTY. The framework still never issues implicit
|
|
236
|
+
HTTP: rows reappear at your app's next explicit fetch.
|
|
237
|
+
|
|
238
|
+
### `refresh` -- how replica data stays fresh
|
|
239
|
+
|
|
240
|
+
v1 ships exactly one policy, and it is the default, so you never write this
|
|
241
|
+
declaration: **manual**. The framework never issues an HTTP request on its
|
|
242
|
+
own. Replica tables change only when your code fetches
|
|
243
|
+
(`Post.all { ... }`, `Post.find(id) { ... }`) or writes through
|
|
244
|
+
(`create`/`update`/`destroy`). Predictable and boring, in the good sense --
|
|
245
|
+
and combined with `watch`, explicit fetching is already reactive: the fetch
|
|
246
|
+
lands, the table changes, every watching component re-renders.
|
|
247
|
+
|
|
248
|
+
The axis exists because it has a future: `refresh :auto`
|
|
249
|
+
(stale-while-revalidate against an authoritative index endpoint) and
|
|
250
|
+
`refresh :live` (ActionCable-pushed replication) are planned as drop-in
|
|
251
|
+
upgrades for models already written with `watch` -- app code will not
|
|
252
|
+
change. Declaring `:auto` or `:live` today raises "not yet supported" at
|
|
253
|
+
class-definition time, as does declaring `refresh` on `:ephemeral` or
|
|
254
|
+
`:local` models.
|
|
255
|
+
|
|
256
|
+
### Table names
|
|
257
|
+
|
|
258
|
+
Table names derive from the class name with naive pluralization: `Post` ->
|
|
259
|
+
`posts`, `Category` -> `categories`. There is no inflector dictionary in the
|
|
260
|
+
browser runtime, so irregular names must be declared:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class Person < Funicular::Model
|
|
264
|
+
table_name "people"
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Associations
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
class Post < Funicular::Model
|
|
272
|
+
belongs_to :user
|
|
273
|
+
has_many :comments
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Associations are local-query sugar over the `<name>_id` convention:
|
|
278
|
+
|
|
279
|
+
- `post.user` runs `User.local.find_by(id: post.user_id)` -- returns an
|
|
280
|
+
instance or `nil`.
|
|
281
|
+
- `post.comments` returns `Comment.local.where(post_id: post.id)` -- a
|
|
282
|
+
chainable Relation: `post.comments.order(:created_at).limit(5)`.
|
|
283
|
+
|
|
284
|
+
Instance-level association readers are unprefixed by design: an instance you
|
|
285
|
+
are holding already came out of the local database (or a fetch that passed
|
|
286
|
+
through it), so its neighborhood reads locally too. The `.local` marker
|
|
287
|
+
belongs at query entry points, where the cache-vs-server decision is made.
|
|
288
|
+
|
|
289
|
+
Options: `class_name:` and `foreign_key:` when the convention does not fit.
|
|
290
|
+
Not supported in v1: `through:`, `includes`/eager loading, polymorphic
|
|
291
|
+
associations. Note that the classic N+1 concern barely applies here -- each
|
|
292
|
+
"+1" is a microsecond query against local memory, not a network round trip.
|
|
293
|
+
|
|
294
|
+
Association targets are resolved lazily, at first use -- model files load in
|
|
295
|
+
sorted filename order, so `belongs_to :user` in `post.rb` must not demand
|
|
296
|
+
the `User` constant while `user.rb` is still unloaded. A typo in the target,
|
|
297
|
+
or a reference to a model the client does not carry, fails with a clear
|
|
298
|
+
error the first time the association is read.
|
|
299
|
+
|
|
300
|
+
Associations are declared on the client, deliberately. Your Rails models may
|
|
301
|
+
have dozens of associations; the client declares only the slice of the graph
|
|
302
|
+
it actually replicates, so the local association surface is exactly what the
|
|
303
|
+
app consciously chose to carry -- nothing auto-generated pointing at tables
|
|
304
|
+
that do not exist here.
|
|
305
|
+
|
|
306
|
+
### `storage :local do ... end` -- table shape and evolution
|
|
307
|
+
|
|
308
|
+
Client-only models define their table inside the `storage` declaration, as a
|
|
309
|
+
sequence of numbered `migrate` blocks:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
class Draft < Funicular::Model
|
|
313
|
+
storage :local do
|
|
314
|
+
migrate 1 do |t|
|
|
315
|
+
t.string :title
|
|
316
|
+
t.text :body
|
|
317
|
+
t.integer :post_id
|
|
318
|
+
t.boolean :pinned, default: false
|
|
319
|
+
t.index :post_id
|
|
320
|
+
t.timestamps # created_at / updated_at, maintained automatically
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The table IS the fold of its `migrate` blocks: block 1 creates it, and every
|
|
327
|
+
later change -- additive or destructive -- is simply the next numbered block:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
storage :local do
|
|
331
|
+
migrate 1 do |t|
|
|
332
|
+
t.string :title
|
|
333
|
+
t.string :body
|
|
334
|
+
t.timestamps
|
|
335
|
+
end
|
|
336
|
+
migrate 2 do |t|
|
|
337
|
+
t.rename :body, :content # destructive steps are ordinary steps
|
|
338
|
+
t.string :status, default: "draft" # in later blocks this is ADD COLUMN
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
There is no separate schema declaration to keep in sync: the blocks are the
|
|
344
|
+
schema, so declaration and migration can never disagree.
|
|
345
|
+
|
|
346
|
+
Builder vocabulary: `t.string` / `t.text` / `t.integer` / `t.float` /
|
|
347
|
+
`t.boolean` / `t.datetime` (options: `default:`, `null:`), `t.timestamps`,
|
|
348
|
+
`t.index` / `t.remove_index`, `t.rename :old, :new`, `t.remove :column`, and
|
|
349
|
+
`t.execute "..."` as the raw-SQL escape hatch for data transformations
|
|
350
|
+
(backfills, splitting a column, ...). Types map to SQLite affinities:
|
|
351
|
+
`string`/`text` -> TEXT, `integer` -> INTEGER, `float` -> REAL, `boolean` ->
|
|
352
|
+
INTEGER (0/1, converted at the Ruby boundary), `datetime` -> TEXT (ISO 8601
|
|
353
|
+
normalized to UTC at fixed precision by the codec -- that normalization is
|
|
354
|
+
what makes string order chronological). Every table gets an implicit
|
|
355
|
+
`id INTEGER PRIMARY KEY`.
|
|
356
|
+
|
|
357
|
+
How migrations run:
|
|
358
|
+
|
|
359
|
+
- Ordinarily the first block is version 1. The one exception: the first
|
|
360
|
+
RETAINED block may carry any positive version when it is a `reset: true`
|
|
361
|
+
baseline (see below) -- that is what makes deleting pre-baseline history
|
|
362
|
+
legal. After the first retained block, versions must be contiguous; a gap
|
|
363
|
+
raises at class-definition time.
|
|
364
|
+
- On boot, the framework compares each table's stored version (kept in a
|
|
365
|
+
meta table inside the local database) with the declared blocks. A fresh
|
|
366
|
+
database, or one stored below the baseline, is created/recreated from the
|
|
367
|
+
baseline definition and then receives the later blocks; a database at or
|
|
368
|
+
above the baseline receives only its missing later blocks. Application is
|
|
369
|
+
per table, inside one transaction; failure rolls back and raises -- user
|
|
370
|
+
data is never left half-migrated. Each `migrate` block is evaluated
|
|
371
|
+
exactly once per migration run (the framework validates and applies the
|
|
372
|
+
same recorded operations); it also runs when column metadata is first
|
|
373
|
+
needed, so keep blocks deterministic and free of side effects.
|
|
374
|
+
- If any table's stored version is NEWER than the declared maximum (a
|
|
375
|
+
rolled-back deploy), the WHOLE local database fails loud: every
|
|
376
|
+
local-model operation -- read or write, any table -- raises
|
|
377
|
+
`Funicular::DB::SchemaTooNewError`, and the database sits at
|
|
378
|
+
`PRAGMA query_only = ON` so raw SQL cannot write either. (A newer deploy
|
|
379
|
+
may have renamed or removed columns; old code cannot be trusted on that
|
|
380
|
+
data, and v1 does not do per-table nuance.) Raw SELECTs against the
|
|
381
|
+
actual on-disk schema remain possible for inspecting or exporting.
|
|
382
|
+
Recovery: `reset_local` on the affected table (the framework internally
|
|
383
|
+
lifts `query_only` for that rebuild; still `ReadOnlyTabError` on
|
|
384
|
+
non-writer tabs) or a fixed-forward deploy.
|
|
385
|
+
|
|
386
|
+
#### Resetting a local table
|
|
387
|
+
|
|
388
|
+
Sometimes migrating is the wrong tool and "throw it away" is the right one.
|
|
389
|
+
Three mechanisms, for three situations:
|
|
390
|
+
|
|
391
|
+
- **Development auto-reset.** If applying migrations fails in the
|
|
392
|
+
development environment, the framework drops the table and rebuilds it
|
|
393
|
+
from the blocks, with a console warning -- iterate on your schema freely.
|
|
394
|
+
This never happens in production.
|
|
395
|
+
- **Release-time reset: `reset: true`.** Mark a block as a new baseline:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
migrate 4, reset: true do |t|
|
|
399
|
+
t.string :title # a complete table definition, not a diff
|
|
400
|
+
t.text :content
|
|
401
|
+
t.timestamps
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Clients below version 4 do not migrate: their table is dropped and
|
|
406
|
+
recreated from this definition, discarding its data -- that is the point.
|
|
407
|
+
Blocks older than a `reset: true` baseline may be deleted from the code;
|
|
408
|
+
clients stranded below the baseline simply fall into the reset path. This
|
|
409
|
+
doubles as the way to squash a long migration history.
|
|
410
|
+
- **Programmatic: `Draft.reset_local`.** Drops and rebuilds the table right
|
|
411
|
+
now -- for a "clear local data" button or console debugging.
|
|
412
|
+
`Funicular::DB.wipe` (below) remains the everything-nuke for logout.
|
|
413
|
+
|
|
414
|
+
## Querying
|
|
415
|
+
|
|
416
|
+
Local queries live under `.local` (see the source-of-truth contract) and
|
|
417
|
+
return immediately. `where`, `order`, `limit`, and `offset` build a lazy,
|
|
418
|
+
chainable Relation; SQL executes once, when you materialize it.
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
rel = Post.local.where(published: true) # no SQL yet
|
|
422
|
+
.order(created_at: :desc) # still no SQL
|
|
423
|
+
.limit(10)
|
|
424
|
+
rel.each { |post| ... } # one SELECT, here
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Conditions
|
|
428
|
+
|
|
429
|
+
Four forms, covering the practical 95%:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
Post.local.where(done: false) # equality ... WHERE done = ?
|
|
433
|
+
Post.local.where(id: [1, 2, 3]) # array ... WHERE id IN (?, ?, ?)
|
|
434
|
+
Post.local.where(created_at: t1..t2) # range ... WHERE created_at BETWEEN ? AND ?
|
|
435
|
+
Post.local.where("published_at < ?", now_iso8601) # raw SQL fragment with placeholders
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Edge semantics are pinned down, ActiveRecord-style:
|
|
439
|
+
|
|
440
|
+
- `where(deleted_at: nil)` generates `IS NULL`, never `= ?`.
|
|
441
|
+
- `where(id: [])` is an always-empty relation (`WHERE 1=0`), not invalid SQL.
|
|
442
|
+
- An inclusive Range (`1..10`) becomes `>= AND <=`; an exclusive Range
|
|
443
|
+
(`1...10`) becomes `>= AND <`.
|
|
444
|
+
- Column names in hash conditions and `order` are validated against the
|
|
445
|
+
model's schema and quoted; unknown columns raise instead of reaching SQL.
|
|
446
|
+
- `offset` without `limit` is legal (emitted as `LIMIT -1 OFFSET n`, which
|
|
447
|
+
is how SQLite spells it).
|
|
448
|
+
- `count` on a limited/offset relation counts the window (a `COUNT(*)` over
|
|
449
|
+
a subquery), matching ActiveRecord.
|
|
450
|
+
- `delete_all` raises if the relation carries `order`/`limit`/`offset` --
|
|
451
|
+
say what you mean with a plain condition. It also exists ONLY for
|
|
452
|
+
`storage :local` models: on a replica Relation it raises
|
|
453
|
+
`Funicular::DB::ReplicaWriteError` (writer tab included) -- the server
|
|
454
|
+
owns replica rows, and deletions reach the replica through write-through
|
|
455
|
+
`destroy`, never through a local bulk delete.
|
|
456
|
+
- Boolean and datetime values cross the Ruby/SQLite boundary through one
|
|
457
|
+
shared codec (`true`/`false` <-> 1/0, `Time` <-> ISO 8601 TEXT normalized
|
|
458
|
+
to UTC at fixed precision -- arbitrary ISO 8601 offsets would not sort
|
|
459
|
+
chronologically as strings) used identically by writes, reads, and
|
|
460
|
+
condition binding. Datetime STRINGS handed to the typed side (a
|
|
461
|
+
`datetime` column in a hash condition or a local write) are parsed and
|
|
462
|
+
re-normalized to the same UTC fixed-precision form; malformed ones raise
|
|
463
|
+
`ArgumentError` at bind time, not at query time. The SAME codec is
|
|
464
|
+
applied when REST responses initialize model instances, so `Post.all`
|
|
465
|
+
and `Post.local.find` return the same Ruby types for the same attribute.
|
|
466
|
+
One boundary: binds on a RAW SQL fragment carry no column type, so they
|
|
467
|
+
are encoded by value (`true`/`false` and `Time` instances converted;
|
|
468
|
+
strings pass through untouched) -- format datetime strings there as UTC
|
|
469
|
+
ISO 8601 yourself, as the examples do.
|
|
470
|
+
|
|
471
|
+
Multiple `where` calls AND together. `OR`, `JOIN`, `GROUP BY`, and anything
|
|
472
|
+
else SQL can do remain available through the raw-fragment form or, for full
|
|
473
|
+
control, `Funicular::DB.replica.execute(sql, binds)` /
|
|
474
|
+
`Funicular::DB.local.execute(sql, binds)`.
|
|
475
|
+
|
|
476
|
+
One rule comes with these guarded handles: framework writes all pass through a
|
|
477
|
+
single apply path that fires table change events (which drive `watch`) and
|
|
478
|
+
schedules snapshot persistence. A raw `execute` that WRITES bypasses both.
|
|
479
|
+
Reads need no ceremony, but after writing raw, tell the framework:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
Funicular::DB.local.execute("UPDATE drafts SET title = TRIM(title)")
|
|
483
|
+
Funicular::DB.notify_changed(Draft) # fire watches + schedule persist
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`notify_changed` takes the model class (preferred -- it knows both the
|
|
487
|
+
table and which database it lives in) or an explicit pair,
|
|
488
|
+
`notify_changed(:local, :drafts)`; a bare table name would be ambiguous
|
|
489
|
+
between the two databases. Called inside a raw transaction, the
|
|
490
|
+
notification and the persistence scheduling are deferred until commit and
|
|
491
|
+
discarded on rollback, like every framework-internal write.
|
|
492
|
+
|
|
493
|
+
### Ordering and slicing
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
Post.local.order(:created_at) # ASC
|
|
497
|
+
Post.local.order(created_at: :desc)
|
|
498
|
+
Post.local.order(:pinned, created_at: :desc) # multiple keys
|
|
499
|
+
Post.local.limit(20).offset(40)
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Materializers
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
relation.each { |m| ... } # enumerate model instances
|
|
506
|
+
relation.to_a # array of model instances
|
|
507
|
+
relation.first # instance or nil (adds LIMIT 1)
|
|
508
|
+
relation.count # SELECT COUNT(*) -- no rows materialized
|
|
509
|
+
relation.exists? # true/false -- SELECT 1 LIMIT 1
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`Post.local` itself is a Relation over the whole table, so everything hangs
|
|
513
|
+
off it directly:
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
Post.local.find(42) # instance, or raises Funicular::RecordNotFound
|
|
517
|
+
Post.local.find_by(id: 42) # instance or nil
|
|
518
|
+
Post.local.count
|
|
519
|
+
Post.local.first
|
|
520
|
+
Post.local.to_a # the whole table
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Because the `.local` namespace has no REST methods in it, the ActiveRecord
|
|
524
|
+
names are all available with their ActiveRecord semantics -- including
|
|
525
|
+
`find`, which on the bare class remains the REST fetch (`Post.find(id) { }`)
|
|
526
|
+
it has always been.
|
|
527
|
+
|
|
528
|
+
Rows come back as instances of your model class -- the same class the REST
|
|
529
|
+
mapper returns -- with attribute readers, validations, and associations.
|
|
530
|
+
|
|
531
|
+
## Writing data
|
|
532
|
+
|
|
533
|
+
### Replica models: writes go through the server
|
|
534
|
+
|
|
535
|
+
The server owns replica data, so writes keep their existing REST form -- and
|
|
536
|
+
the local replica follows automatically:
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
Post.create({ title: "Hello" }) do |post, error|
|
|
540
|
+
# on success the server's authoritative row was upserted into the replica;
|
|
541
|
+
# every watch on Post has already re-rendered
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
post.update(title: "Edited") do |post, error| ... end # post = updated instance
|
|
545
|
+
post.destroy do |ok, error| ... end # ok = true on success
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
This is called write-through: the framework applies the server's response
|
|
549
|
+
(not your request) to the replica, so the local copy always reflects what the
|
|
550
|
+
server actually stored -- server-side defaults, callbacks, and normalizations
|
|
551
|
+
included. The replica is updated BEFORE your callback runs: inside the
|
|
552
|
+
callback, `Post.local.find(post.id)` already sees the applied row. There is
|
|
553
|
+
no local-write API for replica models in v1; optimistic local writes are a
|
|
554
|
+
possible future layer.
|
|
555
|
+
|
|
556
|
+
### Local models: writes are local, synchronous, validated
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
draft = Draft.create(title: "untitled", body: "") # returns the instance
|
|
560
|
+
draft.update(body: "...") # true/false (validations)
|
|
561
|
+
draft.errors # standard validation errors
|
|
562
|
+
draft.destroy # true
|
|
563
|
+
Draft.where("updated_at < ?", cutoff).delete_all # bulk delete
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
No blocks -- these cannot fail like a network call can. Validation failures
|
|
567
|
+
report through `valid?`/`errors` exactly like the REST mapper does today.
|
|
568
|
+
|
|
569
|
+
Local record lifecycle, precisely: a new record is one whose `id` is nil;
|
|
570
|
+
`create` assigns the id from the inserted row. `created_at`/`updated_at`
|
|
571
|
+
(when declared via `t.timestamps`) are maintained automatically; an `update`
|
|
572
|
+
with no actual changes is a no-op that returns true and does not touch
|
|
573
|
+
`updated_at`. SQLite constraint violations (e.g. NOT NULL) escape as
|
|
574
|
+
`SQLite3::Exception` -- they are bugs, not user-facing validation. On
|
|
575
|
+
`storage :local` models the bare class is an alias for `.local`, so
|
|
576
|
+
`Draft.all` returns the whole-table Relation; passing a block to it raises
|
|
577
|
+
(there is no REST side to call).
|
|
578
|
+
|
|
579
|
+
`delete_all` is the only bulk writer. There is deliberately no `update_all`
|
|
580
|
+
(it would bypass validations); the rare true need is served by raw SQL plus
|
|
581
|
+
`Funicular::DB.notify_changed`.
|
|
582
|
+
|
|
583
|
+
## Reactivity: `watch`
|
|
584
|
+
|
|
585
|
+
`watch` is how components consume local data. It binds a state key to a
|
|
586
|
+
Relation:
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
def component_mounted
|
|
590
|
+
watch(:todos) { Todo.local.where(done: false).order(:id) }
|
|
591
|
+
end
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Semantics -- deliberately simple in v1:
|
|
595
|
+
|
|
596
|
+
- The block must return a Relation (anything else raises with a helpful
|
|
597
|
+
message). It runs once immediately; the framework materializes the
|
|
598
|
+
Relation and places the array in `state[:todos]`.
|
|
599
|
+
- The framework subscribes to the Relation's table. Whenever that table
|
|
600
|
+
changes -- fetch-through, write-through, local write, wipe -- the block
|
|
601
|
+
re-runs (and is re-subscribed, in case a branchy block returns a
|
|
602
|
+
different model's Relation this time) and the key is patched, triggering
|
|
603
|
+
a re-render.
|
|
604
|
+
- Subscriptions die with the component; unmount cleans up automatically,
|
|
605
|
+
even when a user lifecycle hook raises on the way out.
|
|
606
|
+
- Re-evaluation is cheap by design: these are microsecond local queries, so
|
|
607
|
+
the framework can afford table-level (coarse) granularity.
|
|
608
|
+
|
|
609
|
+
Derived values -- counts, Hashes combining several queries, raw SQL -- use
|
|
610
|
+
the public primitive plus an ordinary `patch`:
|
|
611
|
+
|
|
612
|
+
```ruby
|
|
613
|
+
def component_mounted
|
|
614
|
+
@todo_sub = Todo.on_change { patch(open_count: Todo.local.where(done: false).count) }
|
|
615
|
+
patch(open_count: Todo.local.where(done: false).count)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def component_will_unmount
|
|
619
|
+
Todo.off_change(@todo_sub)
|
|
620
|
+
end
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Delivery guarantees (also for the `on_change` primitive below):
|
|
624
|
+
|
|
625
|
+
- Change events fire only after the surrounding SQLite transaction commits,
|
|
626
|
+
and at most once per changed table per transaction -- a 50-row reconcile
|
|
627
|
+
is one event, not fifty.
|
|
628
|
+
- Watcher updates are queued and coalesced, never delivered inside another
|
|
629
|
+
component update: a notification arriving mid-`patch` is deferred to the
|
|
630
|
+
next tick instead of being dropped.
|
|
631
|
+
- A subscriber that raises is isolated: it cannot prevent other subscribers,
|
|
632
|
+
or the REST callback that triggered the write, from running.
|
|
633
|
+
|
|
634
|
+
`render` keeps its existing rule: **it reads `state`, nothing else.** `watch`
|
|
635
|
+
exists so that "state" and "live view of the local DB" are the same thing.
|
|
636
|
+
|
|
637
|
+
## Persistence and durability
|
|
638
|
+
|
|
639
|
+
SQLite runs in wasm memory; durability comes from snapshotting a whole
|
|
640
|
+
database into IndexedDB. The framework automates the snapshotting, with
|
|
641
|
+
different policies per database:
|
|
642
|
+
|
|
643
|
+
| | replica DB | local DB |
|
|
644
|
+
|---|---|---|
|
|
645
|
+
| Contains | server-recoverable copies | unrecoverable user data |
|
|
646
|
+
| Auto-persist after a write (`persistent_writer` state only; see Data isolation) | debounced, ~5 s quiet | debounced, ~500 ms quiet |
|
|
647
|
+
| Extra persist | on `visibilitychange` (tab hidden) | on `visibilitychange` |
|
|
648
|
+
| On schema mismatch | dropped and rebuilt | never dropped; migrated |
|
|
649
|
+
|
|
650
|
+
What this means in practice:
|
|
651
|
+
|
|
652
|
+
- **A page reload restores both databases from their last snapshot** (when
|
|
653
|
+
persistence is available -- in the `volatile` state there is no snapshot
|
|
654
|
+
to come back to). The
|
|
655
|
+
replica gives you instant first paint from the previous session's data
|
|
656
|
+
(stale until your fetches revalidate it -- design your
|
|
657
|
+
screens knowing the first frame may be yesterday's data).
|
|
658
|
+
- **A crash or force-closed tab can lose the seconds since the last
|
|
659
|
+
snapshot.** For the replica this is a non-event. For local data the
|
|
660
|
+
window is small (sub-second debounce plus the tab-hidden backstop), but it
|
|
661
|
+
is not zero: browser storage is best-effort, not a transaction log. Data
|
|
662
|
+
the user must never lose should eventually reach the server through a REST
|
|
663
|
+
endpoint; the local DB is not a substitute for that.
|
|
664
|
+
- Browsers may evict IndexedDB under storage pressure. When at least one
|
|
665
|
+
`storage :local` model is declared -- that is, when data actually worth
|
|
666
|
+
protecting exists -- the framework requests persistent storage
|
|
667
|
+
(`navigator.storage.persist()`; note Firefox surfaces this as a user
|
|
668
|
+
prompt). Replica-only apps never trigger the request. Opt out with
|
|
669
|
+
`config.request_persistent_storage = false`. Either way, the final word
|
|
670
|
+
belongs to the browser.
|
|
671
|
+
|
|
672
|
+
To force a snapshot right now (rarely needed):
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
Funicular::DB.flush
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Persistence runs in the background, and background work can fail (quota
|
|
679
|
+
exceeded, IndexedDB errors). Failures are never silent: they are logged
|
|
680
|
+
prominently, and apps that want to react -- warn the user, disable a form --
|
|
681
|
+
can register a hook:
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
Funicular::DB.configure do
|
|
685
|
+
config.on_persist_error = ->(error) { ... }
|
|
686
|
+
end
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Snapshot I/O is owned by the framework, not by the SQLite layer: Funicular
|
|
690
|
+
opens the IndexedDB store itself with the automatic in-memory fallback
|
|
691
|
+
DISABLED, so a silently substituted empty store can never masquerade as
|
|
692
|
+
persistence -- unavailability is detected, classified, and announced as the
|
|
693
|
+
`volatile` state instead. Snapshots are stored Base64-encoded (a raw
|
|
694
|
+
SQLite image is a binary String, which does not survive the JS bridge
|
|
695
|
+
intact -- the same encoding the standalone sqlite3 gem uses).
|
|
696
|
+
|
|
697
|
+
Storage failure at boot has one simple philosophy in v1: **fail loud, fix,
|
|
698
|
+
reload**. There are exactly two behaviors:
|
|
699
|
+
|
|
700
|
+
- **The browser context has no storage BY DESIGN.** Availability errors on
|
|
701
|
+
open (missing global, `SecurityError`, `InvalidStateError`) mean private
|
|
702
|
+
mode or an exotic embedder: the page runs `volatile` (everything works,
|
|
703
|
+
nothing persists -- see Data isolation).
|
|
704
|
+
- **Anything else fails the boot.** A store open error
|
|
705
|
+
(`QuotaExceededError`, `UnknownError`, `VersionError`, a `BlockedError`
|
|
706
|
+
timeout) or a snapshot read error (quota/data on GET) means storage
|
|
707
|
+
exists but could not be used -- snapshots, including unrecoverable local
|
|
708
|
+
data, may well be sitting there, so the framework refuses to start on
|
|
709
|
+
top of them: components do not mount, the failure is written to the
|
|
710
|
+
console, and `config.on_boot_error` receives it. For a corrupt snapshot
|
|
711
|
+
(a GET failure -- the store handle exists), the hook may call
|
|
712
|
+
`Funicular::DB.wipe` to discard it and then reload; for open failures,
|
|
713
|
+
fix the browser state (quota, blocking tabs) and reload. There is no
|
|
714
|
+
partial operation on top of unreadable storage.
|
|
715
|
+
|
|
716
|
+
## Data isolation: users, tabs, and windows
|
|
717
|
+
|
|
718
|
+
### One namespace per application and user
|
|
719
|
+
|
|
720
|
+
Snapshots are stored under a namespace built from two parts: an application
|
|
721
|
+
identifier and an opaque user storage key that the Rails server embeds in
|
|
722
|
+
the page (configured once, server-side, via the gem's existing
|
|
723
|
+
configuration API):
|
|
724
|
+
|
|
725
|
+
```ruby
|
|
726
|
+
# config/initializers/funicular.rb (Rails)
|
|
727
|
+
Funicular.configure do |config|
|
|
728
|
+
config.local_database = true
|
|
729
|
+
config.application_id = "my_app" # default: "funicular"
|
|
730
|
+
config.user_key = ->(controller) {
|
|
731
|
+
controller.current_user&.storage_key # see below
|
|
732
|
+
}
|
|
733
|
+
end
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
The user key should be a **stable, non-reusable** identifier -- a dedicated
|
|
737
|
+
UUID column is ideal. A raw sequential id works but is discouraged: if your
|
|
738
|
+
app ever deletes and re-issues ids, a new account could inherit an old
|
|
739
|
+
account's snapshot. The value is canonicalized with `to_s`; a signed-out
|
|
740
|
+
visitor gets the shared `anonymous` namespace (shared by every signed-out
|
|
741
|
+
visitor of that browser profile -- treat it as scratch space).
|
|
742
|
+
|
|
743
|
+
Internally the identity is never a naive string concatenation -- that would
|
|
744
|
+
let `application_id "a:b"` + user `"c"` collide with `"a"` + `"b:c"`, and a
|
|
745
|
+
real user key that happens to be the string `"anonymous"` collide with the
|
|
746
|
+
signed-out namespace. The framework encodes a typed, versioned tuple
|
|
747
|
+
(`["v1", app_id, "anonymous"]` / `["v1", app_id, "user", key]`) as
|
|
748
|
+
canonical JSON, and that ONE encoded identity is used everywhere it
|
|
749
|
+
matters: snapshot keys, the Web Lock name, the previous-identity value in
|
|
750
|
+
the Rails session, and the epoch-rotation comparison.
|
|
751
|
+
|
|
752
|
+
**Opting in and configuring an identity are both mandatory.** Set
|
|
753
|
+
`config.local_database = true`, then configure `user_key` or explicitly choose
|
|
754
|
+
`anonymous_only`. The gem cannot detect whether your app has authentication, and
|
|
755
|
+
a forgotten `user_key` would silently put every logged-in user into the
|
|
756
|
+
shared `anonymous` namespace AND stop the session epoch from rotating --
|
|
757
|
+
both isolation mechanisms broken at once. So the contract is explicit:
|
|
758
|
+
declare one or the other,
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
Funicular.configure do |config|
|
|
762
|
+
config.local_database = true
|
|
763
|
+
config.user_key = ->(controller) { ... } # apps with authentication
|
|
764
|
+
# or, for apps that genuinely have no users:
|
|
765
|
+
config.anonymous_only = true
|
|
766
|
+
end
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
and an opted-in app without either declaration fails during Rails startup.
|
|
770
|
+
The include helper validates again before emitting metadata, and the client
|
|
771
|
+
boot defensively refuses missing identity metadata. Setting BOTH `user_key`
|
|
772
|
+
and `anonymous_only` is also a configuration error -- the framework never
|
|
773
|
+
picks one silently.
|
|
774
|
+
|
|
775
|
+
The opt-in flag, namespace, and epoch metadata reach the page through
|
|
776
|
+
`picoruby_include_tag` -- the helper every Funicular layout already has --
|
|
777
|
+
as HTML-escaped data attributes, so CSR-only and local-only apps need no
|
|
778
|
+
new helper and no template change. Without the opt-in attribute, the client
|
|
779
|
+
does not infer an anonymous `"funicular"` namespace.
|
|
780
|
+
|
|
781
|
+
Turning the feature off does not delete existing IndexedDB snapshots. If the
|
|
782
|
+
application later enables it with the same identity, those snapshots can be
|
|
783
|
+
restored normally.
|
|
784
|
+
|
|
785
|
+
At boot the client opens only the current namespace's databases. Table
|
|
786
|
+
names, SQL, and your model code are untouched by any of this -- isolation
|
|
787
|
+
happens entirely at the snapshot layer. When user B signs in on a machine
|
|
788
|
+
where user A never logged out (or the browser crashed), user B's boot opens
|
|
789
|
+
user B's namespace and never restores user A's replica or drafts. Cross-user
|
|
790
|
+
isolation does not depend on any logout code running. A pleasant side
|
|
791
|
+
effect on shared machines: when user A signs back in, their drafts are
|
|
792
|
+
still there. (Scope this correctly, though: namespacing stops the FRAMEWORK
|
|
793
|
+
from mixing users' data. It is not a security boundary against JavaScript
|
|
794
|
+
already running in the same origin -- browser storage never is.)
|
|
795
|
+
|
|
796
|
+
If more than one Funicular application shares an origin, give each a
|
|
797
|
+
distinct `application_id`.
|
|
798
|
+
|
|
799
|
+
### The session epoch: when the user changes under a running tab
|
|
800
|
+
|
|
801
|
+
All tabs of a profile share one cookie session, so a login/logout in tab B
|
|
802
|
+
silently changes who tab A's requests authenticate as -- and GET requests
|
|
803
|
+
carry no CSRF check, so they would keep succeeding as the new user. Left
|
|
804
|
+
alone, tab A would apply and persist user B's data into user A's namespace.
|
|
805
|
+
|
|
806
|
+
The framework prevents this with a session epoch, distinct from the storage
|
|
807
|
+
key: an opaque value that changes on every authentication transition. It is
|
|
808
|
+
managed entirely by the gem -- no application code: the Railtie keeps the
|
|
809
|
+
epoch in the Rails session and rotates it (SecureRandom) whenever the
|
|
810
|
+
computed `user_key` differs from the one the session was stamped with, so
|
|
811
|
+
login, logout, and direct user switches all rotate it. Every REST and
|
|
812
|
+
schema response carries the current epoch (an `X-Funicular-Epoch` header);
|
|
813
|
+
the client compares it against the epoch it booted with.
|
|
814
|
+
|
|
815
|
+
A response entering the apply path with a MISSING epoch header is treated
|
|
816
|
+
exactly like a mismatch -- fail closed, never fail open (a stale cached
|
|
817
|
+
response must not sneak through).
|
|
818
|
+
|
|
819
|
+
On mismatch the page enters a TERMINAL invalid-session state:
|
|
820
|
+
|
|
821
|
+
1. the offending response is discarded (never applied to any table),
|
|
822
|
+
2. from that moment on, replica applies, `storage :local` writes, raw
|
|
823
|
+
writes, and snapshot persistence are ALL refused -- permanently, for the
|
|
824
|
+
life of the page; a custom hook that chooses not to reload cannot
|
|
825
|
+
re-enable them,
|
|
826
|
+
3. the `config.on_session_change` hook runs -- its default reloads the
|
|
827
|
+
page, after which the tab boots cleanly in the new session's namespace.
|
|
828
|
+
|
|
829
|
+
The terminal flag is an irreversible latch, independent of the durability
|
|
830
|
+
state.
|
|
831
|
+
|
|
832
|
+
A WRITER tab entering terminal steps down completely: pending debounce
|
|
833
|
+
timers are cancelled, any persist already in flight is serialized with,
|
|
834
|
+
and then the lock-holding promise is resolved so the writer lock is
|
|
835
|
+
RELEASED -- a terminal tab that a custom hook chose not to reload must not
|
|
836
|
+
sit on the old namespace's lock forever, condemning that user's next tab to
|
|
837
|
+
permanent reader status. Its connections remain as a non-persistent read
|
|
838
|
+
view of the moment it died.
|
|
839
|
+
|
|
840
|
+
Override the hook to show a "you were signed out" dialog instead; what you
|
|
841
|
+
cannot do is keep operating, because the page's data and the session no
|
|
842
|
+
longer belong to the same user.
|
|
843
|
+
|
|
844
|
+
### One writer per namespace (multiple tabs)
|
|
845
|
+
|
|
846
|
+
Each tab holds its own in-memory SQLite image; two tabs snapshotting the
|
|
847
|
+
same name would silently overwrite each other ("last persist wins"). The
|
|
848
|
+
framework therefore elects a single writer per namespace using a Web Lock:
|
|
849
|
+
|
|
850
|
+
Every page runs in exactly one of three durability states:
|
|
851
|
+
|
|
852
|
+
- **`persistent_writer`** -- the tab holding the lock. Restores snapshots,
|
|
853
|
+
persists, writes locally and to the replica. Exactly one per namespace.
|
|
854
|
+
- **`persistent_reader`** -- any additional tab, for the LIFE of the page.
|
|
855
|
+
The replica works fully (restored from the latest snapshot; fetch-through
|
|
856
|
+
still applies -- those are in-memory replica writes and are allowed) but
|
|
857
|
+
is never persisted. Writes to `storage :local` models raise
|
|
858
|
+
`Funicular::DB::ReadOnlyTabError`: unrecoverable data is never written
|
|
859
|
+
where it would be silently lost. To write, reload after the writer tab
|
|
860
|
+
has closed -- there is no in-page promotion in v1.
|
|
861
|
+
- **`volatile`** -- everything works, including `storage :local` writes,
|
|
862
|
+
but NOTHING persists: no snapshot restore-to-disk path exists at all
|
|
863
|
+
(persist, flush, and the auto-persist on close are disabled). This is
|
|
864
|
+
the state when coordination or storage is unavailable (below) -- the app
|
|
865
|
+
stays fully functional, durability is honestly zero, and a prominent
|
|
866
|
+
error is logged (plus `config.on_persist_error` once at boot). (The name
|
|
867
|
+
is deliberately NOT "ephemeral": `storage :ephemeral` is a model
|
|
868
|
+
declaration, an unrelated concept.)
|
|
869
|
+
|
|
870
|
+
Which state a page gets -- decided instantly at boot, never by waiting:
|
|
871
|
+
|
|
872
|
+
- Web Locks available: boot requests the lock with `ifAvailable: true`,
|
|
873
|
+
which returns immediately. Granted -> `persistent_writer` (the lock is
|
|
874
|
+
held with a promise that resolves only on page teardown); not granted ->
|
|
875
|
+
`persistent_reader`, permanently for this page.
|
|
876
|
+
- Web Locks API unavailable (very old browsers, some embedded WebViews) ->
|
|
877
|
+
`volatile`; with no way to coordinate, running uncoordinated persistent
|
|
878
|
+
writers is the one thing that must never happen.
|
|
879
|
+
- IndexedDB unavailable (blocking private modes) -> `volatile`.
|
|
880
|
+
|
|
881
|
+
Enforcement lives BELOW the model layer, not in politeness:
|
|
882
|
+
`Funicular::DB.local` / `.replica` hand out guarded proxy handles, never
|
|
883
|
+
the underlying `SQLite3::Database`. The proxy's surface is an explicit
|
|
884
|
+
allowlist, closed against leaking the raw connection: `transaction` is
|
|
885
|
+
implemented by the proxy and yields THE PROXY (never the raw database, as
|
|
886
|
+
the underlying gem's `transaction` would); `prepare` returns a guarded
|
|
887
|
+
statement subject to the same checks; batch execution, `deserialize`, and
|
|
888
|
+
manual `commit`/`rollback` are classified the same way. On a
|
|
889
|
+
`persistent_reader` tab the LOCAL connection runs with
|
|
890
|
+
`PRAGMA query_only = ON` (the replica connection stays writable in memory
|
|
891
|
+
for revalidation) and raw local writes fail. `persist` and `close` are not
|
|
892
|
+
on the proxy surface AT ALL, on any tab: the framework's databases are
|
|
893
|
+
unbound memory databases whose snapshots live in Funicular's own store, so
|
|
894
|
+
the SQLite-level `persist` has no valid target, and `close` would destroy
|
|
895
|
+
a framework-owned connection (the framework closes its connections
|
|
896
|
+
internally, without persisting). Persistence is exclusively
|
|
897
|
+
`Funicular::DB.flush` and the automatic debounce. On a non-writer tab,
|
|
898
|
+
`flush` / `wipe` / `reset_local` raise `ReadOnlyTabError`. There is no
|
|
899
|
+
sequence of public API calls by which a non-writer tab can overwrite the
|
|
900
|
+
snapshot.
|
|
901
|
+
|
|
902
|
+
And `query_only` itself is guarded -- SQLite's pragma is settable, so it is
|
|
903
|
+
not, by itself, a read-only guarantee. In any read-only state the proxy
|
|
904
|
+
checks every statement at EXECUTION time (not just preparation time) using
|
|
905
|
+
SQLite's own writes-or-not classification (`sqlite3_stmt_readonly`,
|
|
906
|
+
exposed as `Statement#readonly?`), converts write statements into the
|
|
907
|
+
appropriate framework exception, and separately rejects
|
|
908
|
+
connection-state-changing statements that SQLite classifies as "read-only"
|
|
909
|
+
(`PRAGMA query_only`, `ATTACH`, `DETACH`). Batch execution is refused
|
|
910
|
+
outright in read-only states.
|
|
911
|
+
|
|
912
|
+
Reader-to-writer promotion and cross-tab live synchronization
|
|
913
|
+
(BroadcastChannel) are post-v1 concerns. A reader tab that needs to write
|
|
914
|
+
reloads once the writer tab is gone -- one keypress, zero protocol.
|
|
915
|
+
|
|
916
|
+
### Private/incognito windows
|
|
917
|
+
|
|
918
|
+
An incognito window is a separate storage partition: separate cookies,
|
|
919
|
+
separate IndexedDB, separate Web Locks. A normal window and an incognito
|
|
920
|
+
window can therefore even be signed in as different users simultaneously
|
|
921
|
+
without seeing or clobbering each other. Within the incognito session
|
|
922
|
+
everything works normally (multiple incognito tabs share one partition and
|
|
923
|
+
one writer lock), but the partition's IndexedDB is ephemeral: snapshots --
|
|
924
|
+
including `storage :local` data -- vanish when the last incognito window
|
|
925
|
+
closes, which is exactly what private mode promises the user. Browsers that
|
|
926
|
+
block IndexedDB in private mode entirely are detected at open time: the
|
|
927
|
+
IndexedDB bridge preserves the DOMException name, and only the explicitly
|
|
928
|
+
listed availability errors (missing global, `SecurityError`,
|
|
929
|
+
`InvalidStateError` on open) drop the page to the `volatile` state -- the
|
|
930
|
+
app still runs, nothing persists. Quota and ordinary data errors do NOT
|
|
931
|
+
silently fall back: switching to an empty in-memory store would masquerade
|
|
932
|
+
as losing the user's previously persisted data, so those surface through
|
|
933
|
+
logging and `config.on_persist_error` instead. (`onblocked` is not an
|
|
934
|
+
availability failure either; it waits and times out with its own error.)
|
|
935
|
+
|
|
936
|
+
## Logout: wiping local data
|
|
937
|
+
|
|
938
|
+
Because namespaces already isolate users, `wipe` is a cleanup tool, not a
|
|
939
|
+
security requirement. Call it when the product wants no trace left on the
|
|
940
|
+
machine (shared terminals, "clear local data" policies):
|
|
941
|
+
|
|
942
|
+
```ruby
|
|
943
|
+
Funicular::DB.wipe
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
One call drops every table in both databases of the CURRENT namespace and
|
|
947
|
+
deletes its snapshots -- the two namespaced keys in Funicular's own
|
|
948
|
+
snapshot store. There is no per-model opt-out; a wipe is a wipe -- partial
|
|
949
|
+
wipes are how leftovers happen. (This is also the recovery path when a
|
|
950
|
+
local snapshot became unreadable; see Persistence.)
|
|
951
|
+
|
|
952
|
+
`wipe` runs on the writer tab (on a non-writer tab it raises
|
|
953
|
+
`ReadOnlyTabError`, like every destructive operation there). On the writer
|
|
954
|
+
it is safe to call at any moment, mid-flight included:
|
|
955
|
+
|
|
956
|
+
- REST responses that were already in flight when the wipe happened are
|
|
957
|
+
discarded, not re-applied -- a logout can never resurrect the previous
|
|
958
|
+
session's rows.
|
|
959
|
+
- Pending persistence timers are cancelled; an in-progress snapshot cannot
|
|
960
|
+
overwrite the cleared state.
|
|
961
|
+
- Watches fire after the databases are rebuilt and queryable again, so
|
|
962
|
+
components re-render onto empty tables rather than crashing onto missing
|
|
963
|
+
ones.
|
|
964
|
+
|
|
965
|
+
## Server-side rendering
|
|
966
|
+
|
|
967
|
+
Local queries do not exist on the server. SSR pages are for SEO and first
|
|
968
|
+
paint; they render from server data passed via `state:`, exactly as before.
|
|
969
|
+
|
|
970
|
+
If a component's server-side render path reaches a local query, it raises
|
|
971
|
+
`Funicular::DB::UnavailableError` with a pointed message -- deliberately loud,
|
|
972
|
+
because silently rendering an empty list would defeat SSR and hide the bug.
|
|
973
|
+
The practical rule: components rendered through SSR read their data from
|
|
974
|
+
state seeded by the controller; `watch`-driven components belong on
|
|
975
|
+
client-rendered routes (or behind `Funicular.server?` guards in
|
|
976
|
+
`component_mounted`, which SSR never calls anyway).
|
|
977
|
+
|
|
978
|
+
## Configuration
|
|
979
|
+
|
|
980
|
+
After the Rails-side opt-in shown in Quick start, runtime DB hooks and tuning
|
|
981
|
+
are optional in `app/funicular/initializer.rb`:
|
|
982
|
+
|
|
983
|
+
```ruby
|
|
984
|
+
Funicular::DB.configure do
|
|
985
|
+
config.replica_debounce_ms = 5000 # default
|
|
986
|
+
config.local_debounce_ms = 500 # default
|
|
987
|
+
config.request_persistent_storage = true # default; see Persistence
|
|
988
|
+
config.on_persist_error = nil # ->(error) { ... }
|
|
989
|
+
config.on_boot_error = nil # ->(errors) { ... }; see boot
|
|
990
|
+
config.on_session_change = nil # default behavior: reload page
|
|
991
|
+
end
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
`Funicular::DB.configure` itself remains valid while the subsystem is
|
|
995
|
+
disabled, so an `on_boot_error` hook can still receive schema-barrier errors;
|
|
996
|
+
the persistence settings are simply inert until the feature is enabled.
|
|
997
|
+
|
|
998
|
+
The user/application namespace and session epoch are configured on the
|
|
999
|
+
Rails side (`Funicular.configure` -- see Data isolation), not here. Future
|
|
1000
|
+
knobs (focus-time revalidation, for one) will land here rather than as new
|
|
1001
|
+
method surface.
|
|
1002
|
+
|
|
1003
|
+
## Limitations and sharp edges (v1)
|
|
1004
|
+
|
|
1005
|
+
- **Whole-database snapshots.** Persistence cost scales with database size,
|
|
1006
|
+
not change size. Replicating tens of thousands of rows will make the
|
|
1007
|
+
5-second snapshot noticeable; replicate what your screens need, not your
|
|
1008
|
+
whole warehouse.
|
|
1009
|
+
- **Memory-bound.** Both databases live in wasm memory. Same advice.
|
|
1010
|
+
- **Binary attributes are not replicated.** This is wire-format reality, not
|
|
1011
|
+
a policy: binary attributes never ride the REST JSON in the first place
|
|
1012
|
+
(they travel through `Funicular::FileUpload`), so there is nothing to put
|
|
1013
|
+
in the replica. Assets that should live client-side (images, files) belong
|
|
1014
|
+
to Blob/object URLs or the Cache API, not a relational table.
|
|
1015
|
+
- **Replica rows need an `id`.** Fetch-through upserts key on it. The
|
|
1016
|
+
local `id` column follows the type the
|
|
1017
|
+
server schema declares -- `INTEGER PRIMARY KEY` for integer ids,
|
|
1018
|
+
`TEXT PRIMARY KEY` for UUID-keyed models. A schema-loaded model whose
|
|
1019
|
+
schema has no `id` cannot be replicated: schema loading raises and the
|
|
1020
|
+
message tells you to declare `storage :ephemeral` on it.
|
|
1021
|
+
- **A local query can yield.** Today queries never suspend, but the runtime
|
|
1022
|
+
reserves the right (a future VFS may perform I/O per statement). Do not
|
|
1023
|
+
assume the world cannot change between two separate queries; a single
|
|
1024
|
+
query is always internally consistent.
|
|
1025
|
+
- **`JOIN`, `OR`, aggregates** beyond `count`: raw SQL escape hatch only.
|
|
1026
|
+
- **No optimistic writes** for replica models: a `create`/`update` shows up
|
|
1027
|
+
locally when the server confirms it, not before.
|
|
1028
|
+
|
|
1029
|
+
## Relationship to `Funicular::Store`
|
|
1030
|
+
|
|
1031
|
+
The local database supersedes the Store layer (`Funicular::Store`,
|
|
1032
|
+
`Store::Singleton`, `Store::Collection`). Store remains available and
|
|
1033
|
+
unchanged for now, but no new features will build on it, and it will be
|
|
1034
|
+
deprecated and removed once `refresh :live` ships. New code should use models
|
|
1035
|
+
and `watch`.
|