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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. 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`.