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
data/mrblib/db.rb ADDED
@@ -0,0 +1,3116 @@
1
+ # Funicular::DB is the client-side database engine behind the local
2
+ # database layer (docs/local_database.md). This file holds the pieces the
3
+ # query layer depends on: the error vocabulary and the shared value codec.
4
+ #
5
+ # SSR contract: this file only defines modules/classes at load time and
6
+ # never touches SQLite3 or JS, so it is safe to load on CRuby.
7
+
8
+ module Funicular
9
+ # Raised by Relation#find (and, on `storage :local` models, the bare-class
10
+ # alias) when no row matches. Named after the ActiveRecord counterpart.
11
+ class RecordNotFound < StandardError; end
12
+
13
+ module DB
14
+ class Error < StandardError; end
15
+
16
+ # `.local` on a `storage :ephemeral` model: there is no table behind it.
17
+ class NoTableError < Error; end
18
+
19
+ # Local write attempted on a tab that lost (or never ran) the writer
20
+ # election, or a destructive operation (flush/wipe/reset_local) there.
21
+ class ReadOnlyTabError < Error; end
22
+
23
+ # Local bulk write attempted on a replica table. The server owns replica
24
+ # rows; deletions reach the replica through write-through destroy.
25
+ class ReplicaWriteError < Error; end
26
+
27
+ # The persisted local schema is NEWER than the code's declarations
28
+ # (deploy rollback). The whole local DB fails loud; see the docs.
29
+ class SchemaTooNewError < Error; end
30
+
31
+ # A local query was materialized where no local database can exist
32
+ # (SSR) or before boot completed.
33
+ class UnavailableError < Error; end
34
+
35
+ # The user_key/anonymous_only/application_id declaration is invalid
36
+ # (docs decision 12); startup must not proceed.
37
+ class ConfigError < Error; end
38
+
39
+ # One shared codec for values crossing the Ruby/SQLite boundary.
40
+ # Applied identically to local writes, reads, condition binds, and REST
41
+ # response initialization, so both sides of a model return the same
42
+ # Ruby types for the same attribute.
43
+ #
44
+ # boolean true/false <-> INTEGER 1/0
45
+ # datetime Time <-> ISO 8601 TEXT normalized to UTC at fixed
46
+ # (second) precision -- arbitrary offsets or precisions
47
+ # would not sort chronologically as strings
48
+ #
49
+ # Every other declared type passes through untouched.
50
+ module Codec
51
+ # Ruby value -> SQLite bind/storage value for a column of `type`.
52
+ def self.encode(type, value)
53
+ return nil if value.nil?
54
+ if type == :boolean
55
+ return 1 if value == true
56
+ return 0 if value == false
57
+ value
58
+ elsif type == :datetime
59
+ if value.is_a?(Time)
60
+ time_to_iso(value)
61
+ elsif value.is_a?(String)
62
+ # Strings are re-normalized (offsets folded into UTC, fractions
63
+ # truncated) so stored TEXT always sorts chronologically;
64
+ # malformed input raises ArgumentError here, not at query time.
65
+ time_to_iso(iso_to_time(value))
66
+ else
67
+ value
68
+ end
69
+ else
70
+ value
71
+ end
72
+ end
73
+
74
+ # SQLite value -> Ruby value for a column of `type`.
75
+ def self.decode(type, value)
76
+ return nil if value.nil?
77
+ if type == :boolean
78
+ return value unless value.is_a?(Integer)
79
+ value == 0 ? false : true
80
+ elsif type == :datetime
81
+ value.is_a?(String) ? iso_to_time(value) : value
82
+ else
83
+ value
84
+ end
85
+ end
86
+
87
+ # Type-less encoding for raw-SQL-fragment binds, where no column (and
88
+ # so no declared type) is known. Converts by value instead.
89
+ def self.encode_bind(value)
90
+ return 1 if value == true
91
+ return 0 if value == false
92
+ return time_to_iso(value) if value.is_a?(Time)
93
+ value
94
+ end
95
+
96
+ # Format a Time as UTC ISO 8601 at second precision. Derived from the
97
+ # epoch (Time#to_i), so the host's local time zone never leaks in.
98
+ def self.time_to_iso(time)
99
+ epoch = time.to_i
100
+ days = epoch / 86400
101
+ secs = epoch % 86400
102
+ civil = civil_from_days(days)
103
+ zpad(civil[0], 4) + "-" + zpad(civil[1], 2) + "-" + zpad(civil[2], 2) +
104
+ "T" + zpad(secs / 3600, 2) + ":" + zpad((secs % 3600) / 60, 2) +
105
+ ":" + zpad(secs % 60, 2) + "Z"
106
+ end
107
+
108
+ # Parse "YYYY-MM-DD[T ]HH:MM:SS[.fff][Z|+HH:MM|-HH:MM]" into a Time.
109
+ # Fractional seconds are truncated (the codec's fixed precision);
110
+ # a missing zone designator is read as UTC. Raises ArgumentError on
111
+ # anything malformed.
112
+ def self.iso_to_time(str)
113
+ len = str.length
114
+ if len < 19
115
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
116
+ end
117
+ year = digits_at(str, 0, 4)
118
+ sep_at(str, 4, 45) # '-'
119
+ mon = digits_at(str, 5, 2)
120
+ sep_at(str, 7, 45) # '-'
121
+ day = digits_at(str, 8, 2)
122
+ t = str.getbyte(10)
123
+ unless t == 84 || t == 32 # 'T' or ' '
124
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
125
+ end
126
+ hour = digits_at(str, 11, 2)
127
+ sep_at(str, 13, 58) # ':'
128
+ min = digits_at(str, 14, 2)
129
+ sep_at(str, 16, 58) # ':'
130
+ sec = digits_at(str, 17, 2)
131
+ if mon < 1 || 12 < mon || day < 1 || days_in_month(year, mon) < day ||
132
+ 23 < hour || 59 < min || 60 < sec
133
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
134
+ end
135
+ pos = 19
136
+ if str.getbyte(pos) == 46 # '.'
137
+ pos += 1
138
+ digit_seen = false
139
+ c = str.getbyte(pos)
140
+ while c && 48 <= c && c <= 57
141
+ digit_seen = true
142
+ pos += 1
143
+ c = str.getbyte(pos)
144
+ end
145
+ unless digit_seen
146
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
147
+ end
148
+ end
149
+ offset = 0
150
+ zone = str.getbyte(pos)
151
+ if zone.nil?
152
+ # no designator: read as UTC
153
+ elsif zone == 90 || zone == 122 # 'Z' or 'z'
154
+ pos += 1
155
+ elsif zone == 43 # '+'
156
+ offset = zone_offset(str, pos)
157
+ pos += 6
158
+ elsif zone == 45 # '-'
159
+ offset = -zone_offset(str, pos)
160
+ pos += 6
161
+ else
162
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
163
+ end
164
+ unless pos == len
165
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
166
+ end
167
+ epoch = days_from_civil(year, mon, day) * 86400 +
168
+ hour * 3600 + min * 60 + sec - offset
169
+ Time.at(epoch)
170
+ end
171
+
172
+ # --- calendar arithmetic (Howard Hinnant's civil algorithms) --------
173
+
174
+ # Days since 1970-01-01 -> [year, month, day].
175
+ def self.civil_from_days(days)
176
+ z = days + 719468
177
+ era = (0 <= z ? z : z - 146096) / 146097
178
+ doe = z - era * 146097
179
+ yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
180
+ y = yoe + era * 400
181
+ doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
182
+ mp = (5 * doy + 2) / 153
183
+ d = doy - (153 * mp + 2) / 5 + 1
184
+ m = mp < 10 ? mp + 3 : mp - 9
185
+ y += 1 if m <= 2
186
+ [y, m, d]
187
+ end
188
+
189
+ # [year, month, day] -> days since 1970-01-01.
190
+ def self.days_from_civil(y, m, d)
191
+ y -= 1 if m <= 2
192
+ era = (0 <= y ? y : y - 399) / 400
193
+ yoe = y - era * 400
194
+ doy = (153 * (m <= 2 ? m + 9 : m - 3) + 2) / 5 + d - 1
195
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
196
+ era * 146097 + doe - 719468
197
+ end
198
+
199
+ def self.days_in_month(year, mon)
200
+ return 31 if mon == 1 || mon == 3 || mon == 5 || mon == 7 ||
201
+ mon == 8 || mon == 10 || mon == 12
202
+ return 30 unless mon == 2
203
+ (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28
204
+ end
205
+
206
+ # Parse the "HH:MM" part of a "+HH:MM" zone tail starting at `pos`
207
+ # (the sign byte) and return it in seconds, always positive.
208
+ def self.zone_offset(str, pos)
209
+ oh = digits_at(str, pos + 1, 2)
210
+ sep_at(str, pos + 3, 58) # ':'
211
+ om = digits_at(str, pos + 4, 2)
212
+ if 23 < oh || 59 < om
213
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
214
+ end
215
+ oh * 3600 + om * 60
216
+ end
217
+
218
+ # Read `len` decimal digits at byte offset `pos` as an Integer.
219
+ def self.digits_at(str, pos, len)
220
+ v = 0
221
+ i = 0
222
+ while i < len
223
+ c = str.getbyte(pos + i)
224
+ if c.nil? || c < 48 || 57 < c
225
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
226
+ end
227
+ v = v * 10 + (c - 48)
228
+ i += 1
229
+ end
230
+ v
231
+ end
232
+
233
+ # Assert the byte at `pos` is `code`.
234
+ def self.sep_at(str, pos, code)
235
+ unless str.getbyte(pos) == code
236
+ raise ArgumentError, "invalid datetime: #{str.inspect}"
237
+ end
238
+ end
239
+
240
+ def self.zpad(n, width)
241
+ s = n.to_s
242
+ while s.length < width
243
+ s = "0" + s
244
+ end
245
+ s
246
+ end
247
+ end
248
+
249
+ # ---- client-only tables: the migrate blocks -------------------------
250
+ #
251
+ # `storage :local do migrate N do |t| ... end end` blocks are recorded
252
+ # by the model DSL and executed here, per table, at boot. The `t`
253
+ # yielded to a block is a TableBuilder: a pure recorder whose
254
+ # operations the runner below renders into DDL, and whose column
255
+ # operations fold into the model's local_columns metadata.
256
+ class TableBuilder
257
+ attr_reader :ops
258
+
259
+ def initialize
260
+ @ops = []
261
+ end
262
+
263
+ def string(name, default: nil, null: true)
264
+ add_column(:string, name, default, null)
265
+ end
266
+
267
+ def text(name, default: nil, null: true)
268
+ add_column(:text, name, default, null)
269
+ end
270
+
271
+ def integer(name, default: nil, null: true)
272
+ add_column(:integer, name, default, null)
273
+ end
274
+
275
+ def float(name, default: nil, null: true)
276
+ add_column(:float, name, default, null)
277
+ end
278
+
279
+ def boolean(name, default: nil, null: true)
280
+ add_column(:boolean, name, default, null)
281
+ end
282
+
283
+ def datetime(name, default: nil, null: true)
284
+ add_column(:datetime, name, default, null)
285
+ end
286
+
287
+ # created_at/updated_at, maintained automatically by the local CRUD.
288
+ def timestamps
289
+ add_column(:datetime, :created_at, nil, true)
290
+ add_column(:datetime, :updated_at, nil, true)
291
+ end
292
+
293
+ def index(*columns)
294
+ @ops << [:index, identifier_list(columns)]
295
+ end
296
+
297
+ def remove_index(*columns)
298
+ @ops << [:remove_index, identifier_list(columns)]
299
+ end
300
+
301
+ def rename(old_name, new_name)
302
+ @ops << [:rename, DB.validate_identifier(old_name),
303
+ DB.validate_identifier(new_name)]
304
+ end
305
+
306
+ def remove(name)
307
+ @ops << [:remove, DB.validate_identifier(name)]
308
+ end
309
+
310
+ # Raw-SQL escape hatch; runs as-is and does not affect the column
311
+ # fold (whatever it does is invisible to local_columns).
312
+ def execute(sql)
313
+ @ops << [:execute, sql]
314
+ end
315
+
316
+ private
317
+
318
+ def add_column(type, name, default, null)
319
+ n = DB.validate_identifier(name)
320
+ if n == "id"
321
+ raise ArgumentError,
322
+ "the id column is implicit (INTEGER PRIMARY KEY); do not declare it"
323
+ end
324
+ @ops << [:add_column, n, type, default, null]
325
+ end
326
+
327
+ def identifier_list(columns)
328
+ if columns.empty?
329
+ raise ArgumentError, "at least one column is required"
330
+ end
331
+ # @type var names: Array[String]
332
+ names = []
333
+ i = 0
334
+ while i < columns.size
335
+ names << DB.validate_identifier(columns[i])
336
+ i += 1
337
+ end
338
+ names
339
+ end
340
+ end
341
+
342
+ SQL_TYPES = {
343
+ string: "TEXT",
344
+ text: "TEXT",
345
+ integer: "INTEGER",
346
+ float: "REAL",
347
+ boolean: "INTEGER",
348
+ datetime: "TEXT",
349
+ }
350
+
351
+ # One key/value metadata table in the local database holds the applied
352
+ # migration version per table (and, later, the replica fingerprint).
353
+ META_TABLE = "funicular_meta"
354
+
355
+ class << self
356
+ # The metadata table belongs to the framework: no model -- local or
357
+ # replica -- may claim its name (SQLite table names are
358
+ # case-insensitive, so the check is too).
359
+ private def guard_reserved_table(table)
360
+ if table.downcase == META_TABLE
361
+ raise ArgumentError,
362
+ "\"#{table}\" is reserved for framework metadata; pick " \
363
+ "another table_name"
364
+ end
365
+ table
366
+ end
367
+ end
368
+
369
+ # SQL identifiers this layer interpolates (table and column names) must
370
+ # be plain: ASCII letter or underscore first, then letters, digits,
371
+ # underscores. Returns the name as a String.
372
+ def self.validate_identifier(name)
373
+ s = name.to_s
374
+ i = 0
375
+ while i < s.length
376
+ c = s.getbyte(i)
377
+ ok = c && (c == 95 || # '_'
378
+ (97 <= c && c <= 122) || # a-z
379
+ (65 <= c && c <= 90) || # A-Z
380
+ (0 < i && 48 <= c && c <= 57)) # 0-9, not first
381
+ unless ok
382
+ raise ArgumentError, "invalid SQL identifier: #{name.inspect}"
383
+ end
384
+ i += 1
385
+ end
386
+ if s.empty?
387
+ raise ArgumentError, "invalid SQL identifier: #{name.inspect}"
388
+ end
389
+ s
390
+ end
391
+
392
+ class << self
393
+ # The index of the block the table is (re)built from: the NEWEST
394
+ # `reset: true` block, or the first block when none is marked. Blocks
395
+ # before it are superseded history -- they may stay in the code (the
396
+ # docs only say they MAY be deleted) but are never folded or applied.
397
+ private def baseline_index(migrations)
398
+ base = 0
399
+ i = 0
400
+ while i < migrations.size
401
+ base = i if migrations[i][:reset]
402
+ i += 1
403
+ end
404
+ base
405
+ end
406
+ end
407
+
408
+ # Fold a model's migrate blocks into column metadata (column name ->
409
+ # declared type), the implicit id included. Pure: no database touched,
410
+ # usable before boot. Folding starts at the baseline, so a reset block
411
+ # may redefine columns that also appear in the superseded history.
412
+ def self.fold_local_columns(model)
413
+ fold_builders(collect_builders(model), model.local_migrations, model)
414
+ end
415
+
416
+ class << self
417
+ # The fold over ALREADY-collected builders: the migration runner
418
+ # evaluates each migrate block exactly once per run and feeds the same
419
+ # recorded operations to this validation and to the DDL.
420
+ private def fold_builders(builders, migrations, model)
421
+ # @type var columns: Hash[String, Symbol]
422
+ columns = { "id" => :integer }
423
+ i = migrations ? baseline_index(migrations) : 0
424
+ while i < builders.size
425
+ fold_ops(columns, builders[i].ops, model)
426
+ i += 1
427
+ end
428
+ columns
429
+ end
430
+ end
431
+
432
+ # Bring one model's local table to its declared schema. Fresh and
433
+ # below-baseline tables are rebuilt from the baseline (see
434
+ # baseline_index); tables between baseline and max get exactly the
435
+ # missing blocks; a
436
+ # table NEWER than the declarations raises SchemaTooNewError (deploy
437
+ # rollback; the whole-DB lockdown is wired at boot). All applied work
438
+ # runs in one transaction. When an incremental upgrade fails in
439
+ # development the table is rebuilt from scratch instead (never in
440
+ # production). Returns the version the table is at afterwards.
441
+ def self.apply_local_migrations(db, model)
442
+ migrations = model.local_migrations
443
+ unless migrations
444
+ raise ArgumentError,
445
+ "#{model} has no migrate blocks (is it storage :local?)"
446
+ end
447
+ # Evaluate every migrate block exactly ONCE for this run, then
448
+ # validate the retained chain before any SQL: the fold catches
449
+ # errors SQLite itself would accept as plain DDL (renaming or
450
+ # removing the implicit id, most notably), and the very same
451
+ # recorded operations feed the DDL below. Fold errors are
452
+ # ArgumentError, so the development auto-reset below never eats
453
+ # them -- a broken declaration fails the same way everywhere.
454
+ builders = collect_builders(model)
455
+ fold_builders(builders, migrations, model)
456
+ table = guard_reserved_table(validate_identifier(model.table_name))
457
+ baseline = migrations[baseline_index(migrations)][:version]
458
+ max = migrations[migrations.size - 1][:version]
459
+ stored = stored_table_version(db, table)
460
+ if max < stored
461
+ raise SchemaTooNewError,
462
+ "\"#{table}\" is at migration #{stored} but the code only " \
463
+ "declares up to #{max} (deploy rollback?); the local database " \
464
+ "refuses to run backwards"
465
+ end
466
+ return max if stored == max
467
+ if stored < baseline
468
+ rebuild_local_table(db, model, builders)
469
+ else
470
+ begin
471
+ db.transaction do
472
+ apply_blocks(db, model, stored, builders)
473
+ store_table_version(db, table, max)
474
+ end
475
+ rescue SQLite3::Exception => e
476
+ # Explicit re-raise: a bare `raise` would not re-raise on the
477
+ # mruby VM (it raises a fresh empty RuntimeError).
478
+ raise e unless Funicular.env.development?
479
+ # Dev auto-reset: a dirty development table beats hand-repair.
480
+ rebuild_local_table(db, model, builders)
481
+ end
482
+ end
483
+ max
484
+ end
485
+
486
+ # Drop and rebuild from the baseline, in one transaction: the fresh
487
+ # path, the below-baseline path, reset_local, and the dev auto-reset
488
+ # all land here. Returns the resulting version.
489
+ def self.rebuild_local_table(db, model, builders = nil)
490
+ migrations = model.local_migrations
491
+ unless migrations
492
+ raise ArgumentError,
493
+ "#{model} has no migrate blocks (is it storage :local?)"
494
+ end
495
+ # When entered directly (reset_local; apply passes its builders in)
496
+ # evaluate the blocks once and validate before any SQL, exactly
497
+ # like apply_local_migrations.
498
+ unless builders
499
+ builders = collect_builders(model)
500
+ fold_builders(builders, migrations, model)
501
+ end
502
+ table = guard_reserved_table(validate_identifier(model.table_name))
503
+ max = migrations[migrations.size - 1][:version]
504
+ db.transaction do
505
+ db.execute("DROP TABLE IF EXISTS \"#{table}\"")
506
+ apply_blocks(db, model, 0, builders)
507
+ store_table_version(db, table, max)
508
+ end
509
+ max
510
+ end
511
+
512
+ def self.stored_table_version(db, table)
513
+ value = read_meta(db, "table_version:#{table}")
514
+ value ? value.to_i : 0
515
+ end
516
+
517
+ def self.store_table_version(db, table, version)
518
+ store_meta(db, "table_version:#{table}", version.to_s)
519
+ end
520
+
521
+ def self.read_meta(db, key)
522
+ ensure_meta_table(db)
523
+ rows = db.execute("SELECT value FROM \"#{META_TABLE}\" WHERE key = ?",
524
+ [key])
525
+ row = rows[0]
526
+ return nil unless row
527
+ row.is_a?(Hash) ? row.values[0] : row[0]
528
+ end
529
+
530
+ def self.store_meta(db, key, value)
531
+ ensure_meta_table(db)
532
+ db.execute("INSERT OR REPLACE INTO \"#{META_TABLE}\" (key, value) " \
533
+ "VALUES (?, ?)", [key, value])
534
+ end
535
+
536
+ class << self
537
+ private def ensure_meta_table(db)
538
+ db.execute("CREATE TABLE IF NOT EXISTS \"#{META_TABLE}\" " \
539
+ "(key TEXT PRIMARY KEY, value TEXT)")
540
+ end
541
+
542
+ # Run every migrate block against a fresh TableBuilder, returning the
543
+ # recorded operations in declaration order. The runner calls this
544
+ # exactly once per migration run; local_columns is the only other
545
+ # caller. Blocks should stay deterministic and side-effect free.
546
+ private def collect_builders(model)
547
+ migrations = model.local_migrations
548
+ unless migrations
549
+ raise ArgumentError,
550
+ "#{model} has no migrate blocks (is it storage :local?)"
551
+ end
552
+ # @type var builders: Array[TableBuilder]
553
+ builders = []
554
+ i = 0
555
+ while i < migrations.size
556
+ t = TableBuilder.new
557
+ migrations[i][:block].call(t)
558
+ builders << t
559
+ i += 1
560
+ end
561
+ builders
562
+ end
563
+
564
+ # Apply one block's column effects to the running fold. Unknown or
565
+ # duplicate names fail here, before any SQL runs.
566
+ private def fold_ops(columns, ops, model)
567
+ i = 0
568
+ while i < ops.size
569
+ op = ops[i]
570
+ kind = op[0]
571
+ if kind == :add_column
572
+ name = op[1]
573
+ if columns.has_key?(name)
574
+ raise ArgumentError,
575
+ "duplicate column #{name.inspect} in #{model.table_name} migrations"
576
+ end
577
+ guard_reserved_column(name, model)
578
+ columns[name] = op[2]
579
+ elsif kind == :rename
580
+ old_name = op[1]
581
+ guard_id(old_name, model)
582
+ type = columns[old_name]
583
+ unless type
584
+ raise ArgumentError,
585
+ "rename of unknown column #{old_name.inspect} in " \
586
+ "#{model.table_name} migrations"
587
+ end
588
+ if columns.has_key?(op[2])
589
+ raise ArgumentError,
590
+ "duplicate column #{op[2].inspect} in #{model.table_name} migrations"
591
+ end
592
+ guard_reserved_column(op[2], model)
593
+ columns.delete(old_name)
594
+ columns[op[2]] = type
595
+ elsif kind == :remove
596
+ name = op[1]
597
+ guard_id(name, model)
598
+ unless columns.has_key?(name)
599
+ raise ArgumentError,
600
+ "remove of unknown column #{name.inspect} in " \
601
+ "#{model.table_name} migrations"
602
+ end
603
+ columns.delete(name)
604
+ end
605
+ # index/remove_index/execute do not affect the fold
606
+ i += 1
607
+ end
608
+ end
609
+
610
+ private def guard_id(name, model)
611
+ if name == "id"
612
+ raise ArgumentError,
613
+ "the id column is implicit and cannot be renamed or removed " \
614
+ "(#{model.table_name} migrations)"
615
+ end
616
+ end
617
+
618
+ # A column name must not shadow the model API: the generated reader
619
+ # would clobber anything the BASE Funicular::Model instance already
620
+ # responds to (destroy, reload, update, valid?, errors, class, hash,
621
+ # ...). Checking against the base class -- never the subclass -- keeps
622
+ # a model's own generated accessors from tripping the guard when its
623
+ # blocks are folded again.
624
+ private def guard_reserved_column(name, model)
625
+ # The __ prefix is the framework-internal namespace (__custom_*
626
+ # writer stashes, __local_* helpers): a column named __custom_title
627
+ # would clobber the alias that wraps a hand-written title= writer.
628
+ if name.start_with?("__")
629
+ raise ArgumentError,
630
+ "column names starting with __ are reserved for framework " \
631
+ "internals (#{model.table_name} migrations); rename " \
632
+ "#{name.inspect}"
633
+ end
634
+ reserved = @reserved_column_names
635
+ unless reserved
636
+ # Both lists: instance_methods alone omits private methods, and a
637
+ # column named "initialize" or "method_missing" must be rejected
638
+ # just as hard as "destroy".
639
+ reserved = Funicular::Model.instance_methods +
640
+ Funicular::Model.private_instance_methods
641
+ @reserved_column_names = reserved
642
+ end
643
+ if reserved.include?(name.to_sym)
644
+ raise ArgumentError,
645
+ "column name #{name.inspect} collides with a Funicular::Model " \
646
+ "method (#{model.table_name} migrations); rename the column"
647
+ end
648
+ end
649
+
650
+ # Apply every block at or after the baseline with version >
651
+ # from_version (pre-baseline history is never applied), using the
652
+ # builders the caller already collected and validated. The first
653
+ # block applied onto a dropped/absent table runs in create mode (its
654
+ # column ops become the CREATE TABLE); everything later alters.
655
+ private def apply_blocks(db, model, from_version, builders)
656
+ migrations = model.local_migrations
657
+ table = validate_identifier(model.table_name)
658
+ i = baseline_index(migrations)
659
+ creating = from_version < migrations[i][:version]
660
+ while i < migrations.size
661
+ if from_version < migrations[i][:version]
662
+ run_block(db, table, builders[i], creating)
663
+ creating = false
664
+ end
665
+ i += 1
666
+ end
667
+ end
668
+
669
+ private def run_block(db, table, builder, create_mode)
670
+ ops = builder.ops
671
+ if create_mode
672
+ # @type var defs: Array[String]
673
+ defs = ["\"id\" INTEGER PRIMARY KEY"]
674
+ i = 0
675
+ while i < ops.size
676
+ op = ops[i]
677
+ defs << column_ddl(op) if op[0] == :add_column
678
+ i += 1
679
+ end
680
+ db.execute("CREATE TABLE \"#{table}\" (#{defs.join(", ")})")
681
+ i = 0
682
+ while i < ops.size
683
+ op = ops[i]
684
+ kind = op[0]
685
+ if kind == :add_column
686
+ # already part of the CREATE TABLE
687
+ elsif kind == :index || kind == :remove_index || kind == :execute
688
+ run_alter_op(db, table, op)
689
+ else
690
+ raise ArgumentError,
691
+ "#{kind} needs an existing table; not allowed in the block " \
692
+ "that creates \"#{table}\""
693
+ end
694
+ i += 1
695
+ end
696
+ else
697
+ i = 0
698
+ while i < ops.size
699
+ run_alter_op(db, table, ops[i])
700
+ i += 1
701
+ end
702
+ end
703
+ end
704
+
705
+ private def run_alter_op(db, table, op)
706
+ kind = op[0]
707
+ if kind == :add_column
708
+ db.execute("ALTER TABLE \"#{table}\" ADD COLUMN #{column_ddl(op)}")
709
+ elsif kind == :rename
710
+ db.execute("ALTER TABLE \"#{table}\" RENAME COLUMN \"#{op[1]}\" " \
711
+ "TO \"#{op[2]}\"")
712
+ elsif kind == :remove
713
+ db.execute("ALTER TABLE \"#{table}\" DROP COLUMN \"#{op[1]}\"")
714
+ elsif kind == :index
715
+ db.execute("CREATE INDEX \"#{index_name(table, op[1])}\" " \
716
+ "ON \"#{table}\" (#{quoted_list(op[1])})")
717
+ elsif kind == :remove_index
718
+ db.execute("DROP INDEX \"#{index_name(table, op[1])}\"")
719
+ elsif kind == :execute
720
+ db.execute(op[1])
721
+ else
722
+ raise ArgumentError, "unknown migration op #{kind.inspect}"
723
+ end
724
+ end
725
+
726
+ # op: [:add_column, name, type, default, null]
727
+ private def column_ddl(op)
728
+ sql = "\"#{op[1]}\" #{SQL_TYPES[op[2]]}"
729
+ default = op[3]
730
+ unless default.nil?
731
+ sql += " DEFAULT #{default_literal(op[2], default)}"
732
+ end
733
+ sql += " NOT NULL" unless op[4]
734
+ sql
735
+ end
736
+
737
+ # Defaults go through the shared codec, so `default: false` stores 0
738
+ # and a Time default stores the canonical UTC string.
739
+ private def default_literal(type, value)
740
+ encoded = Codec.encode(type, value)
741
+ if encoded.is_a?(String)
742
+ "'#{encoded.gsub("'", "''")}'"
743
+ else
744
+ encoded.to_s
745
+ end
746
+ end
747
+
748
+ private def index_name(table, columns)
749
+ "index_#{table}_on_#{columns.join("_")}"
750
+ end
751
+ end
752
+
753
+ # ---- change-event bus (docs decision 10) ----------------------------
754
+ #
755
+ # Framework writes announce themselves per [database role, table];
756
+ # watch and Model.on_change ride these events. They fire POST-COMMIT
757
+ # only: inside a guarded transaction block they coalesce (one event
758
+ # per [role, table]) and flush after COMMIT -- or vanish with the
759
+ # rollback. Delivery drains a queue iteratively, so an event raised
760
+ # BY a subscriber never nests delivery inside delivery, and a
761
+ # raising subscriber is isolated from the rest.
762
+
763
+ def self.subscribe(role, table, &handler)
764
+ unless handler
765
+ raise ArgumentError, "subscribe requires a block"
766
+ end
767
+ validate_role(role)
768
+ subs = (@subscriptions ||= {}) # steep:ignore UnannotatedEmptyCollection
769
+ serial = (@subscription_serial || 0) + 1
770
+ @subscription_serial = serial
771
+ subs[serial] = [role, table.to_s, handler]
772
+ serial
773
+ end
774
+
775
+ def self.unsubscribe(id)
776
+ subs = @subscriptions
777
+ subs.delete(id) if subs
778
+ nil
779
+ end
780
+
781
+ # The raw-SQL protocol (docs, "Querying"): after writing through a
782
+ # guarded handle yourself, tell the framework. Preferred form is the
783
+ # model class -- it knows both its database role and its table; the
784
+ # explicit (role, table) pair exists because a bare table name would
785
+ # be ambiguous between the two databases.
786
+ def self.notify_changed(target, table = nil)
787
+ if table.nil?
788
+ model = target
789
+ if model.ephemeral?
790
+ raise NoTableError,
791
+ "#{model.to_s} is storage :ephemeral; there is no table to " \
792
+ "notify about"
793
+ end
794
+ return notify_changed(model.local? ? :local : :replica,
795
+ model.table_name)
796
+ end
797
+ role = validate_role(target)
798
+ name = table.to_s
799
+ # Defer only under an open transaction on the SAME database: local
800
+ # and replica are separate SQLite databases with independent
801
+ # transactions, so their pending events never mix.
802
+ if 0 < deferral_depth(role)
803
+ seen = pending_seen(role)
804
+ key = "#{role}:#{name}"
805
+ unless seen.has_key?(key)
806
+ seen[key] = true
807
+ pending_order(role) << [role, name]
808
+ end
809
+ else
810
+ enqueue_delivery(role, name)
811
+ end
812
+ nil
813
+ end
814
+
815
+ def self.validate_role(role)
816
+ return role if role == :local
817
+ return role if role == :replica
818
+ raise ArgumentError,
819
+ "role must be :local or :replica, got #{role.inspect}"
820
+ end
821
+
822
+ class << self
823
+ private def deferral_depth(role)
824
+ depths = @deferral_depths
825
+ value = depths ? depths[role] : nil
826
+ value || 0
827
+ end
828
+
829
+ private def pending_seen(role)
830
+ all = (@pending_seen ||= {}) # steep:ignore UnannotatedEmptyCollection
831
+ all[role] ||= {}
832
+ end
833
+
834
+ private def pending_order(role)
835
+ all = (@pending_orders ||= {}) # steep:ignore UnannotatedEmptyCollection
836
+ all[role] ||= []
837
+ end
838
+ end
839
+
840
+ # Transaction hooks (GuardedDatabase), tracked PER ROLE: the local
841
+ # and replica databases transact independently. Only the outermost
842
+ # commit of a role flushes its pending events; a rollback at depth
843
+ # zero discards them.
844
+ def self.__begin_deferral(role)
845
+ depths = (@deferral_depths ||= {}) # steep:ignore UnannotatedEmptyCollection
846
+ depths[role] = deferral_depth(role) + 1
847
+ end
848
+
849
+ def self.__commit_deferral(role)
850
+ depths = (@deferral_depths ||= {}) # steep:ignore UnannotatedEmptyCollection
851
+ depth = deferral_depth(role)
852
+ depths[role] = depth - 1 if 0 < depth
853
+ return unless deferral_depth(role) == 0
854
+ order = pending_order(role)
855
+ clear_pending(role)
856
+ order_size = order.size
857
+ i = 0
858
+ while i < order_size
859
+ event = order[i]
860
+ enqueue_delivery(event[0], event[1])
861
+ i += 1
862
+ end
863
+ resume_deferred_persist(role)
864
+ end
865
+
866
+ def self.__rollback_deferral(role)
867
+ depths = (@deferral_depths ||= {}) # steep:ignore UnannotatedEmptyCollection
868
+ depth = deferral_depth(role)
869
+ depths[role] = depth - 1 if 0 < depth
870
+ return unless deferral_depth(role) == 0
871
+ clear_pending(role)
872
+ # A persist refused mid-transaction re-arms even here: the
873
+ # refusal may have consumed a debounce owed to EARLIER committed
874
+ # writes, and snapshotting the rolled-back (= committed) state is
875
+ # correct.
876
+ resume_deferred_persist(role)
877
+ end
878
+
879
+ class << self
880
+ # A persist that found its database mid-transaction parked itself
881
+ # in @persist_deferred (see persist_snapshot); the settle turns the
882
+ # park into a fresh debounce arm.
883
+ private def resume_deferred_persist(role)
884
+ deferred = @persist_deferred
885
+ return nil unless deferred
886
+ return nil unless deferred[role]
887
+ deferred.delete(role)
888
+ schedule_persist(role)
889
+ nil
890
+ end
891
+
892
+ private def clear_pending(role)
893
+ seen_all = @pending_seen
894
+ seen_all.delete(role) if seen_all
895
+ order_all = @pending_orders
896
+ order_all.delete(role) if order_all
897
+ nil
898
+ end
899
+
900
+ # Delivery belongs to the NEXT tick (docs decision 10): a write that
901
+ # happens during a component update must never patch watchers
902
+ # synchronously into that update. Until the scheduled drain runs,
903
+ # events collapse per [role, table]; the first event of a tick
904
+ # schedules exactly one drain.
905
+ private def enqueue_delivery(role, table)
906
+ # Auto-persist rides the same post-commit funnel (docs decision
907
+ # 11): every event (re)arms the role's debounce timer, and a
908
+ # rollback -- which never reaches here -- schedules nothing.
909
+ schedule_persist(role)
910
+ pending = (@tick_events ||= {}) # steep:ignore UnannotatedEmptyCollection
911
+ key = "#{role}:#{table}"
912
+ unless pending.has_key?(key)
913
+ pending[key] = true
914
+ order = (@tick_order ||= []) # steep:ignore UnannotatedEmptyCollection
915
+ order << [role, table]
916
+ end
917
+ return nil if @drain_scheduled
918
+ @drain_scheduled = true
919
+ schedule_drain
920
+ nil
921
+ end
922
+ end
923
+
924
+ # Boot/tests may install their own scheduler (it must eventually
925
+ # call __drain_events once). The default rides JS setTimeout(0); on
926
+ # CRuby (SSR, native tests) there is no JS event loop AND no
927
+ # component can be mid-update, so draining immediately is safe.
928
+ def self.__set_tick_scheduler(scheduler)
929
+ @tick_scheduler = scheduler
930
+ end
931
+
932
+ class << self
933
+ private def schedule_drain
934
+ scheduler = @tick_scheduler
935
+ if scheduler
936
+ scheduler.call
937
+ elsif Object.const_defined?(:JS)
938
+ JS.global.setTimeout(0) do
939
+ __drain_events
940
+ end
941
+ else
942
+ __drain_events
943
+ end
944
+ nil
945
+ end
946
+
947
+ # Bumped by clear_tick_events: deliveries carrying an older
948
+ # generation are stale and stop, even MID-DRAIN -- a wipe called
949
+ # from inside a subscriber must silence the rest of the drain,
950
+ # which holds its events in a local the buffer clear cannot reach.
951
+ private def tick_generation
952
+ @tick_generation || 0
953
+ end
954
+ end
955
+
956
+ def self.__drain_events
957
+ # Un-schedule FIRST: events raised by the subscribers below belong
958
+ # to the next tick and must get a drain of their own.
959
+ @drain_scheduled = false
960
+ order = @tick_order
961
+ @tick_order = []
962
+ @tick_events = {}
963
+ return nil unless order
964
+ generation = tick_generation
965
+ order_size = order.size
966
+ i = 0
967
+ while i < order_size
968
+ break unless tick_generation == generation
969
+ event = order[i]
970
+ deliver_event(event[0], event[1], generation)
971
+ i += 1
972
+ end
973
+ nil
974
+ end
975
+
976
+ class << self
977
+ private def deliver_event(role, table, generation = nil)
978
+ subs = @subscriptions
979
+ return unless subs
980
+ # Snapshot the ids: a handler may (un)subscribe during delivery.
981
+ ids = subs.keys
982
+ ids_size = ids.size
983
+ i = 0
984
+ while i < ids_size
985
+ # An earlier subscriber of this very event may have staled the
986
+ # delivery (wipe): the remaining subscribers hear only from the
987
+ # wipe's own notification.
988
+ break if generation && !(tick_generation == generation)
989
+ entry = subs[ids[i]]
990
+ if entry && entry[0] == role && entry[1] == table
991
+ begin
992
+ entry[2].call(role, table)
993
+ rescue => e
994
+ # Subscriber isolation: one broken watcher must not starve
995
+ # the others (docs decision 10).
996
+ puts "[Funicular] change subscriber raised: " \
997
+ "#{e.class}: #{e.message}"
998
+ end
999
+ end
1000
+ i += 1
1001
+ end
1002
+ end
1003
+ end
1004
+
1005
+ # ---- guarded database handles (docs decision 15) --------------------
1006
+ #
1007
+ # Funicular::DB.local/.replica hand out these proxies, never raw
1008
+ # connections. The allowlist is CLOSED: persist/close/serialize/
1009
+ # deserialize/backup do not exist here in ANY state, so no caller
1010
+ # can snapshot, close, or swap the database out from under the
1011
+ # framework. Read-only enforcement happens at every execution entry
1012
+ # via Statement#readonly? -- a statement prepared while writable is
1013
+ # still refused once the handle went read-only.
1014
+
1015
+ TRANSACTION_CONTROL_WORDS = [
1016
+ "begin", "commit", "end", "rollback", "savepoint", "release"
1017
+ ].freeze
1018
+
1019
+ # Statements that subvert framework control are rejected in every
1020
+ # state: ATTACH/DETACH escape to other database files, PRAGMA
1021
+ # query_only would lift (or fake) the read-only lockdown, and
1022
+ # transaction control would move the boundary the deferral hangs
1023
+ # off. sqlite3_stmt_readonly classifies all of them as read-only,
1024
+ # so the statement-level check alone would let them through.
1025
+ def self.guard_statement_sql(sql)
1026
+ s = sql.to_s
1027
+ i = skip_sql_blanks(s, 0)
1028
+ head = read_sql_word(s, i)
1029
+ word = head[0]
1030
+ if word == "attach" || word == "detach"
1031
+ raise ArgumentError,
1032
+ "ATTACH/DETACH are not available through the guarded " \
1033
+ "database handle"
1034
+ end
1035
+ # Only the PRAGMA NAME decides (read pragmas stay available):
1036
+ # "PRAGMA table_info(query_only)" is fine, "PRAGMA query_only",
1037
+ # "PRAGMA main.query_only = OFF" and quoted spellings are not.
1038
+ if word == "pragma" && pragma_name(s, head[1]) == "query_only"
1039
+ raise ArgumentError,
1040
+ "PRAGMA query_only is managed by the framework and cannot be " \
1041
+ "issued through the guarded database handle"
1042
+ end
1043
+ # Transaction control belongs to the handle's own transaction /
1044
+ # commit / rollback, which pair every boundary with the event and
1045
+ # persistence deferral (docs decisions 10/11). Issued as raw SQL
1046
+ # the deferral is simply skipped: an execute("BEGIN") lets change
1047
+ # events reach watchers before the commit, and an
1048
+ # execute("ROLLBACK") neither discards the events an open
1049
+ # deferral parked nor resumes the snapshot it postponed. END is
1050
+ # SQLite's synonym for COMMIT, and the savepoint verbs open and
1051
+ # close nested boundaries the same bookkeeping would miss.
1052
+ if TRANSACTION_CONTROL_WORDS.include?(word)
1053
+ raise ArgumentError,
1054
+ "transaction control (#{word.upcase}) is managed by the " \
1055
+ "framework; use the guarded handle's #transaction, #commit " \
1056
+ "and #rollback so change events and snapshots settle with it"
1057
+ end
1058
+ end
1059
+
1060
+ class << self
1061
+ # The first keyword of a statement, lowercased, with leading
1062
+ # whitespace and -- and /* */ comments skipped (a comment prefix
1063
+ # must not smuggle ATTACH past the guard).
1064
+ private def statement_head(sql)
1065
+ s = sql.to_s
1066
+ read_sql_word(s, skip_sql_blanks(s, 0))[0]
1067
+ end
1068
+
1069
+ # The [schema.]name of a PRAGMA statement, starting right after the
1070
+ # PRAGMA keyword (pos). Quoted names ("x", 'x', `x`, [x]) resolve to
1071
+ # their inner text so quoting cannot smuggle query_only past the
1072
+ # guard.
1073
+ private def pragma_name(s, pos)
1074
+ i = skip_sql_blanks(s, pos)
1075
+ token = read_sql_token(s, i)
1076
+ name = token[0]
1077
+ i = skip_sql_blanks(s, token[1])
1078
+ if s.getbyte(i) == 46 # '.': schema-qualified, the name follows
1079
+ token = read_sql_token(s, skip_sql_blanks(s, i + 1))
1080
+ name = token[0]
1081
+ end
1082
+ name
1083
+ end
1084
+
1085
+ # Skip whitespace and -- / /* */ comments; returns the next index.
1086
+ private def skip_sql_blanks(s, i)
1087
+ len = s.length
1088
+ while i < len
1089
+ c = s.getbyte(i)
1090
+ if c == 32 || c == 9 || c == 10 || c == 13
1091
+ i += 1
1092
+ elsif c == 45 && s.getbyte(i + 1) == 45 # "--" line comment
1093
+ i += 2
1094
+ while i < len && !(s.getbyte(i) == 10)
1095
+ i += 1
1096
+ end
1097
+ elsif c == 47 && s.getbyte(i + 1) == 42 # "/*" block comment
1098
+ i += 2
1099
+ while i < len && !(s.getbyte(i) == 42 && s.getbyte(i + 1) == 47)
1100
+ i += 1
1101
+ end
1102
+ i += 2
1103
+ else
1104
+ break
1105
+ end
1106
+ end
1107
+ i
1108
+ end
1109
+
1110
+ # Read a bare identifier/keyword at i: [downcased word, next index].
1111
+ private def read_sql_word(s, i)
1112
+ len = s.length
1113
+ start = i
1114
+ while i < len
1115
+ c = s.getbyte(i)
1116
+ unless c && ((97 <= c && c <= 122) || (65 <= c && c <= 90) ||
1117
+ (48 <= c && c <= 57) || c == 95)
1118
+ break
1119
+ end
1120
+ i += 1
1121
+ end
1122
+ word = s[start, i - start]
1123
+ [word ? word.downcase : "", i]
1124
+ end
1125
+
1126
+ # Like read_sql_word, but also resolves quoted identifiers to their
1127
+ # inner text.
1128
+ private def read_sql_token(s, i)
1129
+ c = s.getbyte(i)
1130
+ closer = nil
1131
+ if c == 91 # [ closes with ]
1132
+ closer = 93
1133
+ elsif c == 34 # "
1134
+ closer = 34
1135
+ elsif c == 39 # '
1136
+ closer = 39
1137
+ elsif c == 96 # `
1138
+ closer = 96
1139
+ end
1140
+ return read_sql_word(s, i) unless closer
1141
+ len = s.length
1142
+ j = i + 1
1143
+ start = j
1144
+ while j < len && !(s.getbyte(j) == closer)
1145
+ j += 1
1146
+ end
1147
+ word = s[start, j - start]
1148
+ [word ? word.downcase : "", j + 1]
1149
+ end
1150
+ end
1151
+
1152
+ # The one execution-time gate every guarded entry point shares:
1153
+ # read-only handles run read-only statements only, no matter when
1154
+ # the statement (or its result set) was created.
1155
+ def self.enforce_read_only(read_only, raw_stmt)
1156
+ if read_only && !raw_stmt.readonly?
1157
+ raise ReadOnlyTabError,
1158
+ "this tab cannot write to the local database (read-only state)"
1159
+ end
1160
+ end
1161
+
1162
+ class GuardedStatement
1163
+ def initialize(stmt, guard)
1164
+ @stmt = stmt
1165
+ @guard = guard
1166
+ end
1167
+
1168
+ def bind_params(*bind_vars)
1169
+ @stmt.bind_params(*bind_vars)
1170
+ end
1171
+
1172
+ def readonly?
1173
+ @stmt.readonly?
1174
+ end
1175
+
1176
+ def columns
1177
+ @stmt.columns
1178
+ end
1179
+
1180
+ def close
1181
+ @stmt.close
1182
+ end
1183
+
1184
+ def closed?
1185
+ @stmt.closed?
1186
+ end
1187
+
1188
+ # Returns materialized rows, never the raw ResultSet (the closed
1189
+ # allowlist would otherwise leak through it).
1190
+ def execute(*bind_vars)
1191
+ check_writable
1192
+ @stmt.execute(*bind_vars).to_a
1193
+ end
1194
+
1195
+ def step
1196
+ check_writable
1197
+ @stmt.step
1198
+ end
1199
+
1200
+ private
1201
+
1202
+ def check_writable
1203
+ DB.enforce_read_only(@guard.read_only?, @stmt)
1204
+ end
1205
+ end
1206
+
1207
+ # Wraps a raw ResultSet so stepping it re-checks the read-only state
1208
+ # every time: a write+RETURNING result set created while writable
1209
+ # must refuse to step after the handle went read-only.
1210
+ class GuardedResultSet
1211
+ def initialize(rs, stmt, guard)
1212
+ @rs = rs
1213
+ @stmt = stmt
1214
+ @guard = guard
1215
+ end
1216
+
1217
+ def next
1218
+ check_writable
1219
+ @rs.next
1220
+ end
1221
+
1222
+ def each
1223
+ row = self.next
1224
+ while row
1225
+ yield row
1226
+ row = self.next
1227
+ end
1228
+ self
1229
+ end
1230
+
1231
+ def to_a
1232
+ # @type var rows: Array[untyped]
1233
+ rows = []
1234
+ row = self.next
1235
+ while row
1236
+ rows << row
1237
+ row = self.next
1238
+ end
1239
+ rows
1240
+ end
1241
+
1242
+ # reset rebinds and re-runs the statement: same gate as stepping.
1243
+ def reset(*bind_params)
1244
+ check_writable
1245
+ @rs.reset(*bind_params)
1246
+ end
1247
+
1248
+ def eof?
1249
+ @rs.eof?
1250
+ end
1251
+
1252
+ def close
1253
+ @rs.close
1254
+ end
1255
+
1256
+ def closed?
1257
+ @rs.closed?
1258
+ end
1259
+
1260
+ def columns
1261
+ @rs.columns
1262
+ end
1263
+
1264
+ def types
1265
+ @rs.types
1266
+ end
1267
+
1268
+ private
1269
+
1270
+ def check_writable
1271
+ DB.enforce_read_only(@guard.read_only?, @stmt)
1272
+ end
1273
+ end
1274
+
1275
+ class GuardedDatabase
1276
+ def initialize(db, role = :local, read_only = false)
1277
+ @db = db
1278
+ @role = DB.validate_role(role)
1279
+ @read_only = read_only
1280
+ end
1281
+
1282
+ # Which database this handle fronts (:local or :replica); event
1283
+ # deferral is tracked per role.
1284
+ def role
1285
+ @role
1286
+ end
1287
+
1288
+ def read_only?
1289
+ @read_only
1290
+ end
1291
+
1292
+ # One-way by design: persistent_reader tabs stay readers for the
1293
+ # life of the page and the terminal latch only ever tightens.
1294
+ def __become_read_only
1295
+ @read_only = true
1296
+ end
1297
+
1298
+ def execute(sql, bind_vars = [])
1299
+ prepare(sql) do |stmt|
1300
+ rows = stmt.execute(*bind_vars)
1301
+ if block_given?
1302
+ rows_size = rows.size
1303
+ i = 0
1304
+ while i < rows_size
1305
+ yield rows[i]
1306
+ i += 1
1307
+ end
1308
+ end
1309
+ rows
1310
+ end
1311
+ end
1312
+
1313
+ def prepare(sql)
1314
+ DB.guard_statement_sql(sql)
1315
+ stmt = GuardedStatement.new(@db.prepare(sql), self)
1316
+ return stmt unless block_given?
1317
+ begin
1318
+ yield stmt
1319
+ ensure
1320
+ stmt.close unless stmt.closed?
1321
+ end
1322
+ end
1323
+
1324
+ # SQLite3::Database#query equivalent: a result set you step through
1325
+ # yourself -- wrapped, so neither the raw connection nor the raw
1326
+ # ResultSet ever surfaces. With a block the set is closed for you.
1327
+ def query(sql, bind_vars = [])
1328
+ DB.guard_statement_sql(sql)
1329
+ stmt = @db.prepare(sql)
1330
+ stmt.bind_params(*bind_vars) unless bind_vars.empty?
1331
+ result = GuardedResultSet.new(
1332
+ SQLite3::ResultSet.new(@db, stmt), stmt, self)
1333
+ return result unless block_given?
1334
+ begin
1335
+ yield result
1336
+ ensure
1337
+ result.close
1338
+ end
1339
+ end
1340
+
1341
+ def get_first_row(sql, bind_vars = [])
1342
+ execute(sql, bind_vars)[0]
1343
+ end
1344
+
1345
+ def get_first_value(sql, bind_vars = [])
1346
+ row = execute(sql, bind_vars)[0]
1347
+ return nil unless row
1348
+ row.is_a?(Hash) ? row.values[0] : row[0]
1349
+ end
1350
+
1351
+ # Yields THIS proxy (docs decision 15), so raw connections never
1352
+ # surface through the transaction block either.
1353
+ def transaction(mode = :deferred)
1354
+ mode_sql = if mode == :deferred
1355
+ "DEFERRED"
1356
+ elsif mode == :immediate
1357
+ "IMMEDIATE"
1358
+ elsif mode == :exclusive
1359
+ "EXCLUSIVE"
1360
+ else
1361
+ raise ArgumentError, "invalid transaction mode #{mode.inspect}"
1362
+ end
1363
+ __execute_control("BEGIN #{mode_sql} TRANSACTION")
1364
+ # Deferral starts with the transaction ITSELF, block or not:
1365
+ # change events raised before COMMIT coalesce and fire only
1366
+ # after it -- or vanish with the rollback (docs decision 10).
1367
+ # commit/rollback below settle it, so the raw blockless form
1368
+ # (transaction / execute / notify_changed / rollback) honors the
1369
+ # same contract.
1370
+ DB.__begin_deferral(@role)
1371
+ return true unless block_given?
1372
+ aborting = false
1373
+ begin
1374
+ yield self
1375
+ rescue => e
1376
+ aborting = true
1377
+ # Explicit re-raise: a bare `raise` would not re-raise on the
1378
+ # mruby VM.
1379
+ raise e
1380
+ ensure
1381
+ aborting ? rollback : commit
1382
+ end
1383
+ end
1384
+
1385
+ def commit
1386
+ __execute_control("COMMIT TRANSACTION")
1387
+ DB.__commit_deferral(@role)
1388
+ true
1389
+ end
1390
+
1391
+ def rollback
1392
+ __execute_control("ROLLBACK TRANSACTION")
1393
+ DB.__rollback_deferral(@role)
1394
+ true
1395
+ end
1396
+
1397
+ # The framework's own transaction boundaries: the same guarded
1398
+ # statement path #execute uses (read-only enforcement included),
1399
+ # minus the guard that refuses these very statements to callers.
1400
+ # Everything that moves a boundary funnels through here, so the
1401
+ # deferral hooks above it always run.
1402
+ private def __execute_control(sql)
1403
+ stmt = GuardedStatement.new(@db.prepare(sql), self)
1404
+ begin
1405
+ stmt.execute
1406
+ ensure
1407
+ stmt.close unless stmt.closed?
1408
+ end
1409
+ nil
1410
+ end
1411
+ end
1412
+
1413
+ # ---- writer election (docs decision 14) -----------------------------
1414
+ #
1415
+ # One tab per namespace persists. The election runs ONCE at boot
1416
+ # with Web Locks' ifAvailable: granted makes this tab the
1417
+ # persistent_writer (the lock is held by a promise resolved only at
1418
+ # release), not granted makes it a persistent_reader for the LIFE of
1419
+ # the page -- no promotion in v1, reload to write. No Web Locks API
1420
+ # at all (or an API failure) means volatile: everything works,
1421
+ # nothing persists.
1422
+
1423
+ # The JS side, installed once via eval. Tests (and exotic hosts) may
1424
+ # inject their own Locks API as globalThis.__funicularLocksApi; the
1425
+ # real navigator.locks is the fallback.
1426
+ LOCK_SHIM_JS = <<~'FUNICULAR_LOCK_JS'
1427
+ (() => {
1428
+ if (globalThis.__funicularLocks) return;
1429
+ globalThis.__funicularLocks = {
1430
+ holds: {},
1431
+ acquire(name) {
1432
+ return new Promise((resolveAcquire) => {
1433
+ // Injection seam: undefined falls through to the real
1434
+ // navigator.locks; anything else (an object, or null to
1435
+ // simulate ABSENCE even where the host has real locks --
1436
+ // Node ships navigator.locks) is taken as-is.
1437
+ const injected = globalThis.__funicularLocksApi;
1438
+ const locks = (injected === undefined)
1439
+ ? (globalThis.navigator && globalThis.navigator.locks)
1440
+ : injected;
1441
+ if (!locks || !locks.request) {
1442
+ resolveAcquire("unsupported");
1443
+ return;
1444
+ }
1445
+ try {
1446
+ const requested = locks.request(
1447
+ name, { ifAvailable: true }, (lock) => {
1448
+ if (!lock) {
1449
+ resolveAcquire("busy");
1450
+ return null;
1451
+ }
1452
+ return new Promise((release) => {
1453
+ globalThis.__funicularLocks.holds[name] = release;
1454
+ resolveAcquire("acquired");
1455
+ });
1456
+ });
1457
+ Promise.resolve(requested).catch(() => {
1458
+ resolveAcquire("error");
1459
+ });
1460
+ } catch (e) {
1461
+ resolveAcquire("error");
1462
+ }
1463
+ });
1464
+ },
1465
+ release(name) {
1466
+ const release = globalThis.__funicularLocks.holds[name];
1467
+ if (!release) return false;
1468
+ delete globalThis.__funicularLocks.holds[name];
1469
+ release();
1470
+ return true;
1471
+ }
1472
+ };
1473
+ })()
1474
+ FUNICULAR_LOCK_JS
1475
+
1476
+ # :unbooted -> :persistent_writer | :persistent_reader | :volatile.
1477
+ # ("volatile" is deliberately distinct from storage :ephemeral.)
1478
+ def self.durability
1479
+ @durability || :unbooted
1480
+ end
1481
+
1482
+ # Boot/test seam; election itself is one-shot.
1483
+ def self.__set_durability(state)
1484
+ @durability = state
1485
+ end
1486
+
1487
+ def self.elect_writer(lock_name)
1488
+ unless durability == :unbooted
1489
+ raise Error,
1490
+ "the writer election already ran (this tab is #{durability})"
1491
+ end
1492
+ # Claim the slot BEFORE awaiting: the await suspends this Task,
1493
+ # and a concurrent elect_writer from another Task must fail the
1494
+ # one-shot check meanwhile -- two elections could otherwise hold
1495
+ # two locks with only one of them releasable.
1496
+ @durability = :electing
1497
+ begin
1498
+ install_lock_shim
1499
+ # @type var shim: untyped
1500
+ shim = JS.global[:__funicularLocks]
1501
+ result = shim.acquire(lock_name).await.to_s
1502
+ rescue => e
1503
+ @durability = :unbooted
1504
+ # Explicit re-raise: a bare `raise` would not re-raise on the
1505
+ # mruby VM.
1506
+ raise e
1507
+ end
1508
+ state = if result == "acquired"
1509
+ :persistent_writer
1510
+ elsif result == "busy"
1511
+ :persistent_reader
1512
+ else
1513
+ # "unsupported" (no Web Locks) or an API error: by-design
1514
+ # absence drops the page to volatile (docs decision 14).
1515
+ :volatile
1516
+ end
1517
+ @writer_lock_name = lock_name if state == :persistent_writer
1518
+ @durability = state
1519
+ state
1520
+ end
1521
+
1522
+ class << self
1523
+ private def install_lock_shim
1524
+ return if @lock_shim_installed
1525
+ # @type var global: untyped
1526
+ global = JS.global
1527
+ global.eval(LOCK_SHIM_JS)
1528
+ @lock_shim_installed = true
1529
+ end
1530
+ end
1531
+
1532
+ # Terminal step-down (docs decision 13) and teardown: resolving the
1533
+ # holding promise lets a NEW tab win the next election. No-op unless
1534
+ # this tab holds the lock.
1535
+ def self.release_writer_lock
1536
+ name = @writer_lock_name
1537
+ return false unless name
1538
+ @writer_lock_name = nil
1539
+ # @type var shim: untyped
1540
+ shim = JS.global[:__funicularLocks]
1541
+ shim.release(name)
1542
+ # Stepping down is one-way: another tab can win the lock from now
1543
+ # on, so THIS tab must never persist again. It becomes a
1544
+ # non-persisting reader, exactly like a tab that lost the
1545
+ # election (docs decision 13).
1546
+ @durability = :persistent_reader
1547
+ true
1548
+ end
1549
+
1550
+ # ---- namespace identity (docs decisions 12/13) ----------------------
1551
+ #
1552
+ # One browser profile can hold data for several apps and several
1553
+ # users, so everything durable -- the two snapshot keys, the Web
1554
+ # Lock name, the identity the Rails session tracks for epoch
1555
+ # rotation -- hangs off ONE identity: a typed, versioned tuple
1556
+ # encoded as canonical JSON. STRUCTURE separates the fields, not a
1557
+ # delimiter, so a user_key of "anonymous" (or one containing any
1558
+ # separator) can never collide with the anonymous identity or with
1559
+ # another application's.
1560
+
1561
+ # ["v1", app, "anonymous"] or ["v1", app, "user", key] as a JSON
1562
+ # string. Empty application_id/user_key fail loud: they would fold
1563
+ # distinct namespaces into one.
1564
+ def self.namespace_identity(application_id, user_key, anonymous)
1565
+ app = application_id.to_s
1566
+ if app.empty?
1567
+ raise ConfigError,
1568
+ "application_id must be configured (Funicular.configure)"
1569
+ end
1570
+ return JSON.generate(["v1", app, "anonymous"]) if anonymous
1571
+ key = user_key.to_s
1572
+ if key.empty?
1573
+ raise ConfigError, "user_key must not be empty"
1574
+ end
1575
+ JSON.generate(["v1", app, "user", key])
1576
+ end
1577
+
1578
+ # The declaration rules, checked authoritatively on the client.
1579
+ # Enabling durable browser storage always requires an explicit identity
1580
+ # contract, including replica-only applications. user_key_configured says
1581
+ # whether a user_key SOURCE is declared at all; user_key is the value it
1582
+ # resolved to for THIS page load -- nil while signed out.
1583
+ def self.resolve_namespace(application_id:, user_key:,
1584
+ user_key_configured:, anonymous_only:)
1585
+ if user_key_configured && anonymous_only
1586
+ raise ConfigError,
1587
+ "user_key and anonymous_only are mutually exclusive; " \
1588
+ "configure exactly one"
1589
+ end
1590
+ if !user_key_configured && !anonymous_only
1591
+ raise ConfigError,
1592
+ "local database is enabled but no user_key is configured; " \
1593
+ "set config.user_key, or anonymous_only = true to accept " \
1594
+ "one shared anonymous namespace"
1595
+ end
1596
+ # nil is the legitimate signed-out state. An EMPTY string is a
1597
+ # broken user_key source: it falls through to namespace_identity's
1598
+ # ConfigError, because silently folding it into the shared
1599
+ # anonymous namespace would mix distinct users' data.
1600
+ namespace_identity(application_id, user_key, user_key.nil?)
1601
+ end
1602
+
1603
+ # Everything durable derives its name from the identity string.
1604
+
1605
+ def self.snapshot_key(identity, role)
1606
+ unless role == :replica || role == :local
1607
+ raise ArgumentError,
1608
+ "role must be :replica or :local, got #{role.inspect}"
1609
+ end
1610
+ "funicular:snapshot:#{role}:#{identity}"
1611
+ end
1612
+
1613
+ def self.lock_name(identity)
1614
+ "funicular:lock:#{identity}"
1615
+ end
1616
+
1617
+ # ---- configuration (docs decision 20: the DB-side knobs) ------------
1618
+ #
1619
+ # Funicular::DB.configure do
1620
+ # config.local_debounce_ms = 200
1621
+ # end
1622
+ #
1623
+ # The block runs with DB as self, so the bareword `config` resolves
1624
+ # here. Identity (application_id/user_key) is NOT configured here --
1625
+ # it arrives from the page, configured on the Rails side (docs
1626
+ # decision 12).
1627
+
1628
+ class Config
1629
+ attr_accessor :replica_debounce_ms
1630
+ attr_accessor :local_debounce_ms
1631
+ attr_accessor :request_persistent_storage
1632
+ attr_accessor :on_persist_error
1633
+ attr_accessor :on_boot_error
1634
+ attr_accessor :on_session_change
1635
+
1636
+ def initialize
1637
+ @replica_debounce_ms = 5000
1638
+ @local_debounce_ms = 500
1639
+ @request_persistent_storage = true
1640
+ @on_persist_error = nil
1641
+ @on_boot_error = nil
1642
+ @on_session_change = nil
1643
+ end
1644
+ end
1645
+
1646
+ def self.config
1647
+ @config ||= Config.new
1648
+ end
1649
+
1650
+ def self.configure(&block)
1651
+ raise ArgumentError, "configure requires a block" unless block
1652
+ instance_exec(&block) # steep:ignore
1653
+ nil
1654
+ end
1655
+
1656
+ # Test seam: configuration is process-global, so per-file test VMs
1657
+ # restore the defaults between tests.
1658
+ def self.__reset_config
1659
+ @config = nil
1660
+ end
1661
+
1662
+ # ---- persistence (docs decisions 11/16) -----------------------------
1663
+ #
1664
+ # Durability = whole-database snapshots (serialize -> Base64) in
1665
+ # Funicular's OWN IndexedDB store, opened with the in-memory
1666
+ # fallback DISABLED: a silently substituted empty store must never
1667
+ # masquerade as persistence. Availability errors mean the browser
1668
+ # context has no storage BY DESIGN -> the volatile state; every
1669
+ # other storage error re-raises, for the boot to fail loud on
1670
+ # (docs decision 16).
1671
+
1672
+ SNAPSHOT_DB_NAME = "funicular"
1673
+
1674
+ # The boot wires the two databases in here. flush/wipe/persist need
1675
+ # the RAW handle (serialize lives outside the guarded allowlist),
1676
+ # which is exactly why this registry has no public getter.
1677
+ def self.__register_database(role, raw_db, models)
1678
+ validate_role(role)
1679
+ registry = (@databases ||= {}) # steep:ignore UnannotatedEmptyCollection
1680
+ registry[role] = [raw_db, models]
1681
+ nil
1682
+ end
1683
+
1684
+ def self.__registered_database(role)
1685
+ registry = @databases
1686
+ entry = registry ? registry[role] : nil
1687
+ entry ? entry[0] : nil
1688
+ end
1689
+
1690
+ # Every snapshot key derives from the namespace identity, resolved
1691
+ # by the boot (docs decision 12).
1692
+ def self.__set_snapshot_identity(identity)
1693
+ @snapshot_identity = identity
1694
+ end
1695
+
1696
+ def self.__set_snapshot_store(store)
1697
+ # Installing a store (or resetting to nil in tests) also clears
1698
+ # the sticky availability-failure mark below.
1699
+ @snapshot_store_unavailable = false
1700
+ @snapshot_store = store
1701
+ end
1702
+
1703
+ def self.snapshot_store
1704
+ @snapshot_store
1705
+ end
1706
+
1707
+ # Open Funicular's snapshot store once. nil (after dropping to
1708
+ # volatile) when the context has no storage BY DESIGN; anything
1709
+ # else -- quota, version, blocked timeouts -- re-raises: storage
1710
+ # exists but could not be used, and snapshots (including
1711
+ # unrecoverable local data) may well be sitting in it.
1712
+ def self.open_snapshot_store
1713
+ store = @snapshot_store
1714
+ return store if store
1715
+ # An availability failure is sticky: nil alone cannot distinguish
1716
+ # "not tried yet" from "classified volatile", and re-trying would
1717
+ # log and fire on_persist_error again -- the announcement happens
1718
+ # ONCE (docs decision 14).
1719
+ return nil if @snapshot_store_unavailable
1720
+ unless Object.const_defined?(:IndexedDB)
1721
+ raise UnavailableError,
1722
+ "IndexedDB does not exist in this environment; " \
1723
+ "snapshots are wasm-only"
1724
+ end
1725
+ begin
1726
+ @snapshot_store = IndexedDB::KVS.open(SNAPSHOT_DB_NAME,
1727
+ fallback: false)
1728
+ rescue IndexedDB::NotSupportedError,
1729
+ IndexedDB::SecurityError,
1730
+ IndexedDB::InvalidStateError => e
1731
+ @snapshot_store_unavailable = true
1732
+ __become_volatile(e)
1733
+ nil
1734
+ end
1735
+ end
1736
+
1737
+ class << self
1738
+ # Private mode or an exotic embedder: everything works, nothing
1739
+ # persists. A held writer lock is released first -- release also
1740
+ # steps @durability down, so volatile is claimed after.
1741
+ private def __become_volatile(error)
1742
+ release_writer_lock
1743
+ @durability = :volatile
1744
+ puts "[Funicular] persistent storage is unavailable; this page " \
1745
+ "runs volatile (everything works, nothing persists): " \
1746
+ "#{error.class}: #{error.message}"
1747
+ invoke_persist_error_hook(error)
1748
+ nil
1749
+ end
1750
+
1751
+ # Persistence failures are never silent (docs decision 11): always
1752
+ # the log, plus the app's hook when registered.
1753
+ private def report_persist_error(error)
1754
+ puts "[Funicular] snapshot persistence failed: " \
1755
+ "#{error.class}: #{error.message}"
1756
+ invoke_persist_error_hook(error)
1757
+ nil
1758
+ end
1759
+
1760
+ private def invoke_persist_error_hook(error)
1761
+ hook = config.on_persist_error
1762
+ return nil unless hook
1763
+ begin
1764
+ hook.call(error)
1765
+ rescue => e
1766
+ puts "[Funicular] on_persist_error hook raised: " \
1767
+ "#{e.class}: #{e.message}"
1768
+ end
1769
+ nil
1770
+ end
1771
+ end
1772
+
1773
+ # Advanced by wipe (docs decision 17): a snapshot captured under an
1774
+ # older generation refuses to land.
1775
+ def self.mutation_generation
1776
+ @mutation_generation || 0
1777
+ end
1778
+
1779
+ # Serialize one database into its namespaced snapshot key. Only the
1780
+ # persistent_writer persists (docs decision 14); on any other state
1781
+ # this is a quiet false. Returns true when the snapshot was
1782
+ # written.
1783
+ def self.persist_snapshot(role)
1784
+ validate_role(role)
1785
+ # Mid-boot the databases are (partly) unrestored while the
1786
+ # election is already won: a snapshot now would overwrite the
1787
+ # stored data with an empty image. The boot itself never
1788
+ # persists, so :booting refuses at this FINAL entry -- flush and
1789
+ # the debounce path funnel through here. A terminated session
1790
+ # (docs decision 13) never persists again either.
1791
+ return false if boot_state == :booting
1792
+ return false if session_terminated?
1793
+ return false unless durability == :persistent_writer
1794
+ db = __registered_database(role)
1795
+ return false unless db
1796
+ store = @snapshot_store
1797
+ return false unless store
1798
+ identity = @snapshot_identity
1799
+ unless identity
1800
+ raise Error,
1801
+ "cannot persist: the namespace identity is not resolved"
1802
+ end
1803
+ # serialize copies the pager's CURRENT pages, uncommitted changes
1804
+ # included -- and a transaction awaiting inside its block lets
1805
+ # other Tasks run, so a debounce timer, the visibilitychange
1806
+ # backstop, or an in-block flush CAN land here mid-transaction.
1807
+ # A snapshot taken now could outlive a rollback and resurrect the
1808
+ # rolled-back rows at the next boot. Defer instead: the
1809
+ # commit/rollback settle re-arms the debounce (docs decision 11).
1810
+ if db.transaction_active?
1811
+ deferred = (@persist_deferred ||= {}) # steep:ignore UnannotatedEmptyCollection
1812
+ deferred[role] = true
1813
+ return false
1814
+ end
1815
+ generation = mutation_generation
1816
+ begin
1817
+ encoded = Base64.encode64(db.serialize)
1818
+ # The store put suspends this Task; a wipe that advanced the
1819
+ # generation since this image was captured must win over it.
1820
+ return false unless mutation_generation == generation
1821
+ # Counted so a terminal step-down (docs decision 13) can wait
1822
+ # for a put that already passed the checks above: the writer
1823
+ # lock must not free the slot while an old-session image is
1824
+ # still landing in the store.
1825
+ @persist_inflight = persist_inflight + 1
1826
+ begin
1827
+ store[snapshot_key(identity, role)] = encoded
1828
+ ensure
1829
+ # Clamped: __reset_boot zeroes the counter while a test's
1830
+ # put may still be in flight, and ITS decrement must not
1831
+ # push the fresh state negative.
1832
+ @persist_inflight = 0 < persist_inflight ? persist_inflight - 1 : 0
1833
+ end
1834
+ true
1835
+ rescue => e
1836
+ report_persist_error(e)
1837
+ false
1838
+ end
1839
+ end
1840
+
1841
+ # Deserialize a stored snapshot into the registered database.
1842
+ # false when no snapshot exists (a first visit). Read errors
1843
+ # propagate: the boot fails loud on top of unreadable storage
1844
+ # (docs decision 16).
1845
+ def self.restore_snapshot(role)
1846
+ validate_role(role)
1847
+ db = __registered_database(role)
1848
+ unless db
1849
+ raise Error, "no #{role} database is registered"
1850
+ end
1851
+ store = @snapshot_store
1852
+ return false unless store
1853
+ identity = @snapshot_identity
1854
+ unless identity
1855
+ raise Error,
1856
+ "cannot restore: the namespace identity is not resolved"
1857
+ end
1858
+ encoded = store[snapshot_key(identity, role)]
1859
+ return false if encoded.nil?
1860
+ db.deserialize(Base64.decode64(encoded.to_s))
1861
+ true
1862
+ end
1863
+
1864
+ # ---- debounced auto-persist (docs decision 11) ----------------------
1865
+ #
1866
+ # Every post-commit change event also (re)arms that role's persist
1867
+ # timer, so writes during the quiet window keep pushing the
1868
+ # snapshot back and a burst costs one serialize. Riding the
1869
+ # enqueue_delivery funnel means transactional writes schedule at
1870
+ # COMMIT and a rollback schedules nothing.
1871
+
1872
+ class << self
1873
+ private def schedule_persist(role)
1874
+ return nil unless durability == :persistent_writer
1875
+ return nil unless @snapshot_store
1876
+ return nil unless Object.const_defined?(:JS)
1877
+ timers = (@persist_timers ||= {}) # steep:ignore UnannotatedEmptyCollection
1878
+ existing = timers[role]
1879
+ # @type var global: untyped
1880
+ global = JS.global
1881
+ global.clearTimeout(existing) if existing
1882
+ # The clear can come too late: a timer whose JS deadline already
1883
+ # passed has its callback QUEUED on the Ruby side, beyond
1884
+ # clearTimeout's reach. Every (re)arm therefore bumps the role's
1885
+ # token, and only the callback holding the current token acts.
1886
+ tokens = (@persist_timer_tokens ||= {}) # steep:ignore UnannotatedEmptyCollection
1887
+ token = (tokens[role] || 0) + 1
1888
+ tokens[role] = token
1889
+ cfg = config
1890
+ ms = role == :local ? cfg.local_debounce_ms : cfg.replica_debounce_ms
1891
+ timers[role] = global.setTimeout(ms) do
1892
+ __persist_timer_fired(role, token)
1893
+ end
1894
+ nil
1895
+ end
1896
+ end
1897
+
1898
+ # The armed timer's landing point. A stale callback (its timer was
1899
+ # re-armed or cancelled after the JS deadline passed) must neither
1900
+ # drop the replacement timer's bookkeeping nor snapshot: it would
1901
+ # cut the quiet window short and leave the replacement uncancelable.
1902
+ def self.__persist_timer_fired(role, token)
1903
+ tokens = @persist_timer_tokens
1904
+ return nil unless tokens
1905
+ return nil unless tokens[role] == token
1906
+ timers = @persist_timers
1907
+ timers.delete(role) if timers
1908
+ persist_snapshot(role)
1909
+ nil
1910
+ end
1911
+
1912
+ # Test seam for the staleness protocol above.
1913
+ def self.__persist_timer_token(role)
1914
+ tokens = @persist_timer_tokens
1915
+ tokens ? tokens[role] : nil
1916
+ end
1917
+
1918
+ def self.cancel_persist_timers
1919
+ # Cancelling covers parked persists too (a caller about to
1920
+ # persist NOW, or wipe, wants no leftover re-arm at settle).
1921
+ @persist_deferred = nil
1922
+ # Invalidate the tokens IN PLACE first: a fired-but-not-yet-run
1923
+ # callback already sits in the queue holding the current token,
1924
+ # and clearTimeout cannot recall it.
1925
+ tokens = @persist_timer_tokens
1926
+ if tokens
1927
+ keys = tokens.keys
1928
+ keys_size = keys.size
1929
+ i = 0
1930
+ while i < keys_size
1931
+ key = keys[i]
1932
+ tokens[key] = tokens[key] + 1
1933
+ i += 1
1934
+ end
1935
+ end
1936
+ timers = @persist_timers
1937
+ return nil unless timers
1938
+ @persist_timers = {}
1939
+ return nil unless Object.const_defined?(:JS)
1940
+ # @type var global: untyped
1941
+ global = JS.global
1942
+ roles = timers.keys
1943
+ roles_size = roles.size
1944
+ i = 0
1945
+ while i < roles_size
1946
+ handle = timers[roles[i]]
1947
+ global.clearTimeout(handle) if handle
1948
+ i += 1
1949
+ end
1950
+ nil
1951
+ end
1952
+
1953
+ class << self
1954
+ private def persist_inflight
1955
+ @persist_inflight || 0
1956
+ end
1957
+
1958
+ # Decision 13's "serialize with in-flight persist": a put that
1959
+ # already passed persist_snapshot's checks keeps running inside
1960
+ # the store across an await, beyond any flag's reach. A terminal
1961
+ # step-down waits here BEFORE releasing the writer lock --
1962
+ # otherwise a fresh tab could win the election and restore while
1963
+ # the old session's image is still landing over its store.
1964
+ private def await_inflight_persists
1965
+ return nil unless Object.const_defined?(:JS)
1966
+ while 0 < persist_inflight
1967
+ # @type var global: untyped
1968
+ global = JS.global
1969
+ global.eval("new Promise((r) => setTimeout(r, 10))").await
1970
+ end
1971
+ nil
1972
+ end
1973
+ end
1974
+
1975
+ # Immediate snapshot of both databases (docs decision 11). On a
1976
+ # persistent_reader every persisting operation raises; volatile has
1977
+ # no store to write, so flush is an honest no-op -- everything
1978
+ # works, nothing persists. Inside an open transaction a database
1979
+ # cannot be serialized (see persist_snapshot); its snapshot defers
1980
+ # to the commit/rollback settle and flush reports false.
1981
+ def self.flush
1982
+ __ensure_local_database_enabled(:flush)
1983
+ state = durability
1984
+ if state == :persistent_reader
1985
+ raise ReadOnlyTabError,
1986
+ "flush requires the writer tab " \
1987
+ "(this tab is a persistent_reader)"
1988
+ end
1989
+ return false unless state == :persistent_writer
1990
+ cancel_persist_timers
1991
+ # Every REGISTERED database, and never short-circuiting: a local
1992
+ # snapshot that defers (transaction) or fails must not skip the
1993
+ # replica, and the pair reports success only when all of it
1994
+ # landed. A caller about to navigate away reads this return
1995
+ # value as "the data is safe" -- one deferred role makes that a
1996
+ # lie. A role with no database behind it (a page with no local
1997
+ # models, say) is nothing to persist and does not count.
1998
+ roles = [:replica, :local]
1999
+ wrote = 0
2000
+ missed = 0
2001
+ i = 0
2002
+ while i < roles.size
2003
+ role = roles[i]
2004
+ i += 1
2005
+ next unless __registered_database(role)
2006
+ if persist_snapshot(role)
2007
+ wrote += 1
2008
+ else
2009
+ missed += 1
2010
+ end
2011
+ end
2012
+ 0 < wrote && missed == 0
2013
+ end
2014
+
2015
+ # The tab-hidden backstop (docs decision 11): debounce alone would
2016
+ # lose the quiet-window tail when the user switches away and the
2017
+ # browser freezes the page. Installed by the boot; false where no
2018
+ # document exists (Node, SSR).
2019
+ def self.__install_visibility_hook
2020
+ return false if @visibility_hook_installed
2021
+ return false unless Object.const_defined?(:JS)
2022
+ # @type var global: untyped
2023
+ global = JS.global
2024
+ present = global.eval(
2025
+ "typeof document === 'undefined' ? 'no' : 'yes'").to_s
2026
+ return false unless present == "yes"
2027
+ @visibility_hook_installed = true
2028
+ # Not JS.document: it insists on a real DOM Element, and this
2029
+ # hook only needs addEventListener (tests fake the document).
2030
+ global[:document].addEventListener("visibilitychange") do
2031
+ __visibility_flush
2032
+ end
2033
+ true
2034
+ end
2035
+
2036
+ # Hidden -> persist NOW: the page may never come back.
2037
+ def self.__visibility_flush
2038
+ return nil unless durability == :persistent_writer
2039
+ return nil unless Object.const_defined?(:JS)
2040
+ # @type var global: untyped
2041
+ global = JS.global
2042
+ state = global.eval(
2043
+ "typeof document === 'undefined' ? '' : " \
2044
+ "String(document.visibilityState)").to_s
2045
+ return nil unless state == "hidden"
2046
+ cancel_persist_timers
2047
+ persist_snapshot(:replica)
2048
+ persist_snapshot(:local)
2049
+ nil
2050
+ end
2051
+
2052
+ # navigator.storage.persist() (docs decision 11): asked by the boot
2053
+ # only when a storage :local model exists -- data actually worth
2054
+ # protecting; replica-only apps never prompt. The browser has the
2055
+ # final word, the result is informational. The __funicularStorageApi
2056
+ # seam mirrors the locks shim: only `undefined` falls through to
2057
+ # the real navigator.storage.
2058
+ STORAGE_PERSIST_JS = <<~'FUNICULAR_STORAGE_JS'
2059
+ (() => {
2060
+ const injected = globalThis.__funicularStorageApi;
2061
+ const storage = (injected === undefined)
2062
+ ? (globalThis.navigator && globalThis.navigator.storage)
2063
+ : injected;
2064
+ if (!storage || !storage.persist) {
2065
+ return Promise.resolve("unsupported");
2066
+ }
2067
+ try {
2068
+ return Promise.resolve(storage.persist()).then(
2069
+ (granted) => (granted ? "granted" : "denied"),
2070
+ () => "error");
2071
+ } catch (e) {
2072
+ return Promise.resolve("error");
2073
+ }
2074
+ })()
2075
+ FUNICULAR_STORAGE_JS
2076
+
2077
+ def self.request_persistent_storage
2078
+ return :disabled unless config.request_persistent_storage
2079
+ return :unsupported unless Object.const_defined?(:JS)
2080
+ # @type var global: untyped
2081
+ global = JS.global
2082
+ result = global.eval(STORAGE_PERSIST_JS).await.to_s
2083
+ if result == "granted"
2084
+ :granted
2085
+ elsif result == "denied"
2086
+ :denied
2087
+ elsif result == "error"
2088
+ :error
2089
+ else
2090
+ :unsupported
2091
+ end
2092
+ end
2093
+
2094
+ # ---- wipe (docs decision 17) ----------------------------------------
2095
+ #
2096
+ # The everything-nuke for logout and for discarding a corrupt local
2097
+ # snapshot: both databases of the CURRENT namespace dropped and
2098
+ # rebuilt empty, both snapshot keys deleted. Namespacing already
2099
+ # isolates users by construction, so this is a cleanup tool, not a
2100
+ # security requirement -- but it must be safe to call mid-flight.
2101
+
2102
+ # In-flight work issued before a wipe must be discarded, never
2103
+ # applied: REST verbs capture the generation at issue time and
2104
+ # check it at response time, and persist_snapshot re-checks it
2105
+ # before the store put.
2106
+ def self.stale_generation?(token)
2107
+ !(token == mutation_generation)
2108
+ end
2109
+
2110
+ def self.stale_response_error
2111
+ Error.new(
2112
+ "the local data was wiped while this request was in flight; " \
2113
+ "the response was discarded")
2114
+ end
2115
+
2116
+ # Writer-only like every destructive operation (ReadOnlyTabError on
2117
+ # a persistent_reader); volatile is fine too -- the only tab, all
2118
+ # memory, no snapshots to delete.
2119
+ def self.wipe
2120
+ __ensure_local_database_enabled(:wipe)
2121
+ # The terminal latch first: on a terminated volatile page the
2122
+ # durability checks below would still let this raw path through.
2123
+ ensure_session_not_terminated(:wipe)
2124
+ # Like reset_local, wipe reaches the RAW databases, so a
2125
+ # mid-boot wipe must be kept out (durability is already elected
2126
+ # then). :failed stays allowed: wiping from on_boot_error IS the
2127
+ # official corrupt-snapshot recovery (docs decision 16) -- the
2128
+ # durability check below still requires the failure to have
2129
+ # happened after the election.
2130
+ unless boot_state == :ready || boot_state == :failed
2131
+ raise Error,
2132
+ "wipe requires a booted page (boot state: #{boot_state})"
2133
+ end
2134
+ state = durability
2135
+ if state == :persistent_reader
2136
+ raise ReadOnlyTabError,
2137
+ "wipe requires the writer tab " \
2138
+ "(this tab is a persistent_reader)"
2139
+ end
2140
+ unless state == :persistent_writer || state == :volatile
2141
+ raise Error, "wipe requires a booted page (this tab is #{state})"
2142
+ end
2143
+ # A wipe is all-or-nothing. With a transaction open on either
2144
+ # database the rebuild below would nest its transactions -- or be
2145
+ # swallowed into the caller's and resurrected by its ROLLBACK --
2146
+ # so refuse BEFORE any side effect: generation, snapshots, and
2147
+ # tables stay untouched.
2148
+ #
2149
+ # ORDERING INVARIANT: from this check to the end of the rebuild,
2150
+ # this Task never suspends -- everything in between is
2151
+ # synchronous SQLite/C (timer and drain REGISTRATION included),
2152
+ # so no other Task can slip a new transaction in behind the
2153
+ # check. The snapshot deletes, the only awaiting operations,
2154
+ # follow the rebuild, and the notifications come after THOSE.
2155
+ ensure_not_in_transaction(:local)
2156
+ ensure_not_in_transaction(:replica)
2157
+ # 1. Advance the generation FIRST: from here on, in-flight REST
2158
+ # responses and already-captured snapshot images are stale.
2159
+ @mutation_generation = mutation_generation + 1
2160
+ # 2. No armed or parked persist survives (queued timer callbacks
2161
+ # go stale with their tokens), and change events queued BEFORE
2162
+ # the wipe are discarded: an already-scheduled drain would
2163
+ # deliver them during the snapshot deletes below, letting a
2164
+ # watcher observe the wiped tables before -- or without -- the
2165
+ # deletes succeeding. The wipe's own notifications (step 5)
2166
+ # are the only events that may tell of it.
2167
+ cancel_persist_timers
2168
+ clear_tick_events
2169
+ # 3. Drop + rebuild both databases.
2170
+ wipe_role(:local)
2171
+ wipe_role(:replica)
2172
+ # 4. Delete the snapshots. Running after the rebuild is safe: the
2173
+ # generation bump already made every pre-wipe image stale, and
2174
+ # a put issued before the wipe is overwritten by these deletes
2175
+ # (IndexedDB runs same-store readwrite transactions in issue
2176
+ # order). Nothing races them from our side either -- no
2177
+ # post-wipe persist is armed until the notifications below.
2178
+ store = @snapshot_store
2179
+ identity = @snapshot_identity
2180
+ if store && identity
2181
+ store.delete(snapshot_key(identity, :local))
2182
+ store.delete(snapshot_key(identity, :replica))
2183
+ end
2184
+ # 5. Notify LAST -- the databases are queryable and the old
2185
+ # snapshots are really gone. A failing delete raises out of
2186
+ # wipe with no watcher told and no fresh persist armed:
2187
+ # components must never render a "wiped" state whose old
2188
+ # snapshot could still resurrect on reload.
2189
+ notify_wiped(:local)
2190
+ notify_wiped(:replica)
2191
+ true
2192
+ end
2193
+
2194
+ class << self
2195
+ # Empty the next-tick delivery buffer AND stale a drain that is
2196
+ # already running (it holds its events in a local; the generation
2197
+ # bump is what reaches it). A drain merely scheduled stays
2198
+ # scheduled and no-ops; the next enqueue schedules a fresh one.
2199
+ private def clear_tick_events
2200
+ @tick_generation = tick_generation + 1
2201
+ @tick_order = []
2202
+ @tick_events = {}
2203
+ nil
2204
+ end
2205
+
2206
+ # Decision 13's terminal latch is independent of durability: a
2207
+ # terminated VOLATILE page keeps :volatile (only a writer steps
2208
+ # down to :persistent_reader), so the raw rebuild paths -- wipe
2209
+ # and reset_local -- need their own refusal on top of the
2210
+ # read-only proxies.
2211
+ private def ensure_session_not_terminated(operation)
2212
+ return nil unless session_terminated?
2213
+ raise ReadOnlyTabError,
2214
+ "#{operation} refused: the session changed; this page is " \
2215
+ "terminal (reload to continue)"
2216
+ end
2217
+
2218
+ private def ensure_not_in_transaction(role)
2219
+ db = __registered_database(role)
2220
+ return nil unless db
2221
+ if db.transaction_active?
2222
+ raise Error,
2223
+ "the #{role} database has an open transaction; settle it " \
2224
+ "(commit or rollback) before rebuilding"
2225
+ end
2226
+ nil
2227
+ end
2228
+
2229
+ private def wipe_role(role)
2230
+ registry = @databases
2231
+ entry = registry ? registry[role] : nil
2232
+ return nil unless entry
2233
+ db = entry[0]
2234
+ return nil unless db
2235
+ models = entry[1]
2236
+ drop_all_tables(db)
2237
+ models_size = models.size
2238
+ if role == :local
2239
+ i = 0
2240
+ while i < models_size
2241
+ # The meta table is gone, so every table reads as version 0
2242
+ # and rebuilds from its baseline.
2243
+ apply_local_migrations(db, models[i])
2244
+ i += 1
2245
+ end
2246
+ else
2247
+ # The stored fingerprint is gone too: the fresh-boot path
2248
+ # recreates the declared tables and stores it again.
2249
+ build_replica_tables(db, models)
2250
+ end
2251
+ nil
2252
+ end
2253
+
2254
+ # One change event per wiped table, sent only from wipe's step 5 --
2255
+ # strictly after the rebuild AND the snapshot deletes.
2256
+ private def notify_wiped(role)
2257
+ registry = @databases
2258
+ entry = registry ? registry[role] : nil
2259
+ return nil unless entry
2260
+ return nil unless entry[0]
2261
+ models = entry[1]
2262
+ models_size = models.size
2263
+ i = 0
2264
+ while i < models_size
2265
+ notify_changed(role, models[i].table_name)
2266
+ i += 1
2267
+ end
2268
+ nil
2269
+ end
2270
+
2271
+ private def drop_all_tables(db)
2272
+ rows = db.execute(
2273
+ "SELECT name FROM sqlite_master WHERE type = 'table' " \
2274
+ "AND name NOT LIKE 'sqlite_%'")
2275
+ rows_size = rows.size
2276
+ i = 0
2277
+ while i < rows_size
2278
+ row = rows[i]
2279
+ name = row.is_a?(Hash) ? row.values[0] : row[0]
2280
+ # Unlike model-declared names, this comes from sqlite_master and
2281
+ # may be any legal SQLite identifier. Double embedded quotes.
2282
+ quoted_name = name.to_s.gsub('"', '""')
2283
+ db.execute("DROP TABLE IF EXISTS \"#{quoted_name}\"")
2284
+ i += 1
2285
+ end
2286
+ nil
2287
+ end
2288
+ end
2289
+
2290
+ # ---- boot (docs decision 19) ----------------------------------------
2291
+ #
2292
+ # The client-side boot: everything above wired together in the one
2293
+ # order that works, driven by Funicular.start (a later change).
2294
+ # Failure philosophy is decision 16's fail loud: any error stops
2295
+ # the boot, components stay unmounted (wired with start), and
2296
+ # on_boot_error hears about it. SchemaTooNew is NOT a failure --
2297
+ # the local database completes the boot locked down instead.
2298
+
2299
+ def self.boot_state
2300
+ @boot_state || :unbooted
2301
+ end
2302
+
2303
+ def self.__set_boot_state(state)
2304
+ @boot_state = state
2305
+ end
2306
+
2307
+ # The page's session epoch, held for the HTTP layer (a later
2308
+ # change: X-Funicular-Epoch mismatch -> terminal latch).
2309
+ def self.session_epoch
2310
+ @session_epoch
2311
+ end
2312
+
2313
+ # Metadata comes from the page (the picoruby_include_tag data
2314
+ # attributes); tests and embedders may pass it directly. Missing opt-in
2315
+ # or identity metadata is a configuration error. Absent models fall back
2316
+ # to every declared Model subclass.
2317
+ def self.boot(models: nil, metadata: nil)
2318
+ if Funicular.server?
2319
+ raise UnavailableError,
2320
+ "Funicular::DB.boot does not run on the server (SSR renders " \
2321
+ "from state, not from a local database)"
2322
+ end
2323
+ page_metadata = metadata || __page_metadata
2324
+ unless metadata_value(page_metadata, :local_database, false)
2325
+ raise ConfigError,
2326
+ "local database is disabled; set config.local_database = true"
2327
+ end
2328
+ @local_database_latched = true
2329
+ @local_database_enabled = true
2330
+ unless boot_state == :unbooted
2331
+ raise Error, "DB.boot already ran (state: #{boot_state})"
2332
+ end
2333
+ @boot_state = :booting
2334
+ begin
2335
+ __boot_steps(models || Funicular::Model.__registered_models,
2336
+ page_metadata)
2337
+ @boot_state = :ready
2338
+ true
2339
+ rescue => e
2340
+ __fail_boot([e])
2341
+ end
2342
+ end
2343
+
2344
+ # The one failure funnel (docs decision 16): boot's own rescue and
2345
+ # the schema barrier (funicular.rb) land here. Fails loud, tears
2346
+ # the partial boot out of reach, and -- once the hook had its
2347
+ # recovery chance (a wipe from it runs as the writer) -- releases
2348
+ # the writer slot: a page that stays failed must not deny it to
2349
+ # every other tab. Always returns false.
2350
+ def self.__fail_boot(errors)
2351
+ @boot_state = :failed
2352
+ @local_handle = nil
2353
+ @replica_handle = nil
2354
+ report_boot_error(errors)
2355
+ release_writer_lock
2356
+ false
2357
+ end
2358
+
2359
+ # Schema loading still runs for REST-only applications. Its failures use
2360
+ # the same console/hook reporting without pretending that a disabled DB
2361
+ # entered (and failed) its lifecycle.
2362
+ def self.__report_boot_errors(errors)
2363
+ report_boot_error(errors)
2364
+ false
2365
+ end
2366
+
2367
+ # The picoruby_include_tag embeds the namespace identity and the
2368
+ # session epoch as HTML-escaped data attributes (docs decision 12);
2369
+ # this is the client half of that contract. No opt-in tag (or no document
2370
+ # in tests and exotic embedders) means that the subsystem is disabled.
2371
+ PAGE_METADATA_JS = <<~'FUNICULAR_META_JS'
2372
+ (() => {
2373
+ if (typeof document === "undefined") return "null";
2374
+ const el = document.querySelector(
2375
+ '[data-funicular-local-database="true"]');
2376
+ if (!el) return "null";
2377
+ const d = el.dataset;
2378
+ return JSON.stringify({
2379
+ local_database: true,
2380
+ application_id: d.funicularApplicationId,
2381
+ user_key:
2382
+ (d.funicularUserKey === undefined) ? null : d.funicularUserKey,
2383
+ user_key_configured: d.funicularUserKeyConfigured === "true",
2384
+ anonymous_only: d.funicularAnonymousOnly === "true",
2385
+ epoch: (d.funicularEpoch === undefined) ? null : d.funicularEpoch,
2386
+ });
2387
+ })()
2388
+ FUNICULAR_META_JS
2389
+
2390
+ def self.read_page_metadata
2391
+ return {} unless Object.const_defined?(:JS)
2392
+ # @type var global: untyped
2393
+ global = JS.global
2394
+ raw = global.eval(PAGE_METADATA_JS).to_s
2395
+ return {} if raw == "null" || raw.empty?
2396
+ data = JSON.parse(raw)
2397
+ {
2398
+ local_database: !!data["local_database"],
2399
+ application_id: data["application_id"],
2400
+ user_key: data["user_key"],
2401
+ user_key_configured: !!data["user_key_configured"],
2402
+ anonymous_only: !!data["anonymous_only"],
2403
+ epoch: data["epoch"],
2404
+ }
2405
+ end
2406
+
2407
+ # Page metadata is immutable for the life of an application boot. Lazy
2408
+ # latching lets model class bodies load without a DOM and keeps an early
2409
+ # top-level local API call consistent with the later start/boot decision.
2410
+ def self.__page_metadata
2411
+ metadata = @page_metadata
2412
+ return metadata if @page_metadata_latched && metadata
2413
+ @page_metadata_latched = true
2414
+ @page_metadata = read_page_metadata
2415
+ end
2416
+
2417
+ def self.local_database_enabled?
2418
+ return false if Funicular.server?
2419
+ return !!@local_database_enabled if @local_database_latched
2420
+ @local_database_latched = true
2421
+ metadata = __page_metadata
2422
+ @local_database_enabled =
2423
+ !!metadata_value(metadata, :local_database, false)
2424
+ end
2425
+
2426
+ # Test/embedder seam for code that installs DB state directly instead of
2427
+ # entering through page metadata and DB.boot.
2428
+ def self.__set_local_database_enabled(value)
2429
+ @local_database_latched = true
2430
+ @local_database_enabled = value ? true : false
2431
+ end
2432
+
2433
+ # Runtime local APIs use UnavailableError. Configuration entry points
2434
+ # (DB.boot/Funicular.start) use ConfigError instead.
2435
+ def self.__ensure_local_database_enabled(operation)
2436
+ if Funicular.server?
2437
+ raise UnavailableError,
2438
+ "#{operation}: the local database is unavailable on the server"
2439
+ end
2440
+ unless local_database_enabled?
2441
+ raise UnavailableError,
2442
+ "#{operation}: local database is disabled; " \
2443
+ "set config.local_database = true"
2444
+ end
2445
+ true
2446
+ end
2447
+
2448
+ # ---- session epoch: the terminal latch (docs decision 13) -----------
2449
+ #
2450
+ # The Rails side stamps X-Funicular-Epoch on every REST/schema
2451
+ # response and rotates it when the session's user changes. A
2452
+ # mismatch means this page belongs to a session that no longer
2453
+ # exists: NOTHING may be applied, written, or persisted again for
2454
+ # the life of the page.
2455
+
2456
+ # Test/boot seam; the boot fills it from the page metadata.
2457
+ def self.__set_session_epoch(value)
2458
+ @session_epoch = value
2459
+ end
2460
+
2461
+ # Reads the page's epoch attribute ONCE and holds the result --
2462
+ # nil included, so an epoch-less page never re-reads the DOM per
2463
+ # response. The schema barrier calls this BEFORE its first request
2464
+ # leaves, and __session_epoch_ok? latches lazily for everything
2465
+ # else: pre-boot HTTP (an ephemeral model's REST call, a direct
2466
+ # HTTP.get at app init) is epoch-checked too, not only traffic
2467
+ # after DB.boot -- the boot alone would latch too late.
2468
+ def self.__latch_page_epoch
2469
+ return nil unless local_database_enabled?
2470
+ epoch = @session_epoch
2471
+ return epoch if epoch
2472
+ return nil if @page_epoch_latched
2473
+ @page_epoch_latched = true
2474
+ @session_epoch = metadata_value(__page_metadata, :epoch, nil)
2475
+ end
2476
+
2477
+ def self.session_terminated?
2478
+ !!@session_terminated
2479
+ end
2480
+
2481
+ # The gate every Funicular::HTTP response passes through (http.rb):
2482
+ # true = the response may be delivered and applied. No page epoch
2483
+ # means the feature is off (no Rails integration yet). With one
2484
+ # expected, a MISSING header counts as a mismatch too. The
2485
+ # expected value latches lazily right here, so the very first
2486
+ # response a page ever receives is already checked.
2487
+ def self.__session_epoch_ok?(header)
2488
+ return true unless local_database_enabled?
2489
+ expected = __latch_page_epoch
2490
+ return true unless expected
2491
+ return false if session_terminated?
2492
+ unless header == expected
2493
+ __terminate_session(header)
2494
+ return false
2495
+ end
2496
+ true
2497
+ end
2498
+
2499
+ # Irreversible. A writer steps down completely: no pending or new
2500
+ # persist survives, the lock frees the writer slot for a fresh tab,
2501
+ # and both connections remain only as a non-persistent read view.
2502
+ def self.__terminate_session(header)
2503
+ return nil if @session_terminated
2504
+ @session_terminated = true
2505
+ puts "[Funicular] session epoch mismatch (expected " \
2506
+ "#{@session_epoch.inspect}, got #{header.inspect}): this " \
2507
+ "page is terminal; reload to continue"
2508
+ cancel_persist_timers
2509
+ # The handles close BEFORE the wait below suspends this Task:
2510
+ # nothing may write into the read view through that window.
2511
+ handle = @local_handle
2512
+ handle.__become_read_only if handle
2513
+ handle = @replica_handle
2514
+ handle.__become_read_only if handle
2515
+ # A put that already passed persist_snapshot's terminal check
2516
+ # keeps running inside the store; the lock frees the writer slot
2517
+ # only once it landed (docs decision 13: a terminal writer
2518
+ # serializes with the in-flight persist).
2519
+ await_inflight_persists
2520
+ release_writer_lock
2521
+ hook = config.on_session_change
2522
+ if hook
2523
+ begin
2524
+ hook.call
2525
+ rescue => e
2526
+ puts "[Funicular] on_session_change hook raised: " \
2527
+ "#{e.class}: #{e.message}"
2528
+ end
2529
+ elsif Object.const_defined?(:JS)
2530
+ # Default behavior: reload into the new session.
2531
+ # @type var global: untyped
2532
+ global = JS.global
2533
+ global.eval(
2534
+ "typeof location === 'undefined' ? null : location.reload()")
2535
+ end
2536
+ nil
2537
+ end
2538
+
2539
+ class << self
2540
+ private def __boot_steps(models, metadata)
2541
+ # @type var local_models: Array[untyped]
2542
+ local_models = []
2543
+ # @type var replica_models: Array[untyped]
2544
+ replica_models = []
2545
+ i = 0
2546
+ models_size = models.size
2547
+ while i < models_size
2548
+ model = models[i]
2549
+ if model.local?
2550
+ local_models << model
2551
+ elsif model.replica?
2552
+ replica_models << model
2553
+ end
2554
+ i += 1
2555
+ end
2556
+ identity = resolve_namespace(
2557
+ application_id: metadata_value(metadata, :application_id,
2558
+ nil),
2559
+ user_key: metadata_value(metadata, :user_key, nil),
2560
+ user_key_configured: !!metadata_value(metadata,
2561
+ :user_key_configured, false),
2562
+ anonymous_only: !!metadata_value(metadata, :anonymous_only, false))
2563
+ __set_snapshot_identity(identity)
2564
+ @session_epoch = metadata_value(metadata, :epoch, nil)
2565
+ # A mismatch detected before the boot even started (the schema
2566
+ # barrier latches the epoch first) must not boot a stale page.
2567
+ ensure_boot_not_terminated
2568
+ elect_writer(lock_name(identity))
2569
+ # The election suspended this Task: a response landing in that
2570
+ # window found no lock and no handles to tear down, so the
2571
+ # boot itself notices and aborts -- __fail_boot releases the
2572
+ # lock the election acquired AFTER the termination.
2573
+ ensure_boot_not_terminated
2574
+ # Availability errors drop to volatile inside; anything else
2575
+ # re-raises and fails the boot (docs decision 16).
2576
+ open_snapshot_store
2577
+ ensure_boot_not_terminated
2578
+ local_db = SQLite3::Database.new(":memory:")
2579
+ replica_db = SQLite3::Database.new(":memory:")
2580
+ __register_database(:local, local_db, local_models)
2581
+ __register_database(:replica, replica_db, replica_models)
2582
+ restore_snapshot(:local)
2583
+ ensure_boot_not_terminated
2584
+ begin
2585
+ i = 0
2586
+ local_size = local_models.size
2587
+ while i < local_size
2588
+ apply_local_migrations(local_db, local_models[i])
2589
+ i += 1
2590
+ end
2591
+ rescue SchemaTooNewError => e
2592
+ # Decision 7: the WHOLE local database fails loud, but the
2593
+ # boot itself completes -- raw SELECT export must survive a
2594
+ # deploy rollback.
2595
+ engage_schema_lockdown(local_db, e)
2596
+ end
2597
+ restore_snapshot(:replica)
2598
+ ensure_boot_not_terminated
2599
+ build_replica_tables(replica_db, replica_models)
2600
+ __install_handles(local_db, replica_db)
2601
+ request_persistent_storage unless local_models.empty?
2602
+ # The storage request was the last await before :ready; a
2603
+ # termination inside it already closed the fresh handles, and
2604
+ # the abort here keeps the page off :ready entirely.
2605
+ ensure_boot_not_terminated
2606
+ __install_visibility_hook
2607
+ nil
2608
+ end
2609
+
2610
+ # __boot_steps suspends at the election and at every storage
2611
+ # await: an epoch mismatch detected by a response landing in one
2612
+ # of those windows (docs decision 13) had nothing to tear down
2613
+ # yet, so every suspension re-checks and aborts the boot through
2614
+ # the ordinary failure funnel.
2615
+ private def ensure_boot_not_terminated
2616
+ return nil unless session_terminated?
2617
+ raise Error,
2618
+ "the session changed during boot; this page is terminal " \
2619
+ "(reload to continue)"
2620
+ end
2621
+
2622
+ private def metadata_value(metadata, key, default)
2623
+ return default unless metadata
2624
+ metadata.has_key?(key) ? metadata[key] : default
2625
+ end
2626
+
2627
+ # Boot failures are never silent (docs decision 16): always the
2628
+ # console, plus the app's hook when registered. The hook receives
2629
+ # the ARRAY of errors -- the schema barrier (a later change)
2630
+ # reports several at once.
2631
+ private def report_boot_error(errors)
2632
+ errors_size = errors.size
2633
+ i = 0
2634
+ while i < errors_size
2635
+ e = errors[i]
2636
+ message = "[Funicular] boot failed: #{e.class}: #{e.message}"
2637
+ if Object.const_defined?(:JS)
2638
+ # @type var global: untyped
2639
+ global = JS.global
2640
+ global[:console].error(message)
2641
+ else
2642
+ puts message
2643
+ end
2644
+ i += 1
2645
+ end
2646
+ hook = config.on_boot_error
2647
+ if hook
2648
+ begin
2649
+ hook.call(errors)
2650
+ rescue => hook_error
2651
+ puts "[Funicular] on_boot_error hook raised: " \
2652
+ "#{hook_error.class}: #{hook_error.message}"
2653
+ end
2654
+ end
2655
+ nil
2656
+ end
2657
+
2658
+ private def __install_handles(local_db, replica_db)
2659
+ # Defense in depth for decision 13: the boot aborts on a
2660
+ # mid-boot termination before ever landing here, but if it DID
2661
+ # land here terminal, the handles must come up as the
2662
+ # non-persistent read view, never writable.
2663
+ terminated = session_terminated?
2664
+ read_only = durability == :persistent_reader || terminated
2665
+ # Belt (the proxy refuses at every execution entry) and braces
2666
+ # (SQLite itself refuses): docs decision 15. The replica handle
2667
+ # stays memory-writable even on a reader -- fetch-through
2668
+ # revalidation works there.
2669
+ local_db.execute("PRAGMA query_only = ON") if read_only
2670
+ @local_handle = GuardedDatabase.new(local_db, :local, read_only)
2671
+ @replica_handle = GuardedDatabase.new(replica_db, :replica,
2672
+ terminated)
2673
+ nil
2674
+ end
2675
+ end
2676
+
2677
+ # The raw-SQL escape hatches (docs decision 20): guarded proxies,
2678
+ # never raw connections. Everything gates on boot_state == :ready,
2679
+ # not on the handle's existence -- mid-boot (an await inside
2680
+ # __boot_steps lets other Tasks run) and after a failed boot the
2681
+ # handles must be equally out of reach.
2682
+ def self.local
2683
+ __ensure_local_database_enabled(:local)
2684
+ handle = boot_state == :ready ? @local_handle : nil
2685
+ unless handle
2686
+ raise UnavailableError, "the local database is not booted"
2687
+ end
2688
+ handle
2689
+ end
2690
+
2691
+ def self.replica
2692
+ __ensure_local_database_enabled(:replica)
2693
+ handle = boot_state == :ready ? @replica_handle : nil
2694
+ unless handle
2695
+ raise UnavailableError, "the replica database is not booted"
2696
+ end
2697
+ handle
2698
+ end
2699
+
2700
+ # The funnel every Model-level local operation goes through
2701
+ # (Model.local_db): select the database for the model's storage role,
2702
+ # check readiness, then apply the local-only SchemaTooNew latch.
2703
+ def self.__model_local_db(model)
2704
+ __ensure_local_database_enabled(model.to_s)
2705
+ replica = model.replica?
2706
+ handle = if boot_state == :ready
2707
+ replica ? @replica_handle : @local_handle
2708
+ end
2709
+ unless handle
2710
+ raise UnavailableError,
2711
+ "#{model.to_s}: the #{replica ? 'replica' : 'local'} database " \
2712
+ "is not booted"
2713
+ end
2714
+ __check_schema_lockdown(model.to_s) unless replica
2715
+ handle
2716
+ end
2717
+
2718
+ def self.__model_replica_db
2719
+ boot_state == :ready ? @replica_handle : nil
2720
+ end
2721
+
2722
+ # ---- SchemaTooNew lockdown (docs decision 7) ------------------------
2723
+ #
2724
+ # stored version > declared max means a deploy rollback: the whole
2725
+ # local database refuses to run backwards. Model-level operations
2726
+ # raise; the raw DB.local handle keeps SELECT working (query_only
2727
+ # blocks writes at the SQLite level) so the user's data can still
2728
+ # be exported.
2729
+
2730
+ class << self
2731
+ private def engage_schema_lockdown(db, error)
2732
+ @schema_lockdown = error
2733
+ db.execute("PRAGMA query_only = ON")
2734
+ puts "[Funicular] local database locked down " \
2735
+ "(SchemaTooNew): #{error.message}"
2736
+ nil
2737
+ end
2738
+ end
2739
+
2740
+ def self.schema_lockdown
2741
+ @schema_lockdown
2742
+ end
2743
+
2744
+ class << self
2745
+ private def __check_schema_lockdown(context)
2746
+ error = @schema_lockdown
2747
+ return nil unless error
2748
+ raise SchemaTooNewError,
2749
+ "#{context}: the local database is locked down (#{error.message})"
2750
+ end
2751
+ end
2752
+
2753
+ # Model.reset_local lands here: drop + rebuild ONE table from its
2754
+ # baseline, on the writer (or volatile) tab. When the database sat
2755
+ # locked down, the whole declared set revalidates -- v1 has no
2756
+ # per-table nuance, so the lockdown lifts only when every table
2757
+ # passes again.
2758
+ def self.reset_local_table(model)
2759
+ __ensure_local_database_enabled(:reset_local)
2760
+ # The terminal latch first: on a terminated volatile page the
2761
+ # durability check below would still let this raw path through.
2762
+ ensure_session_not_terminated(:reset_local)
2763
+ # Same :ready gate as the handles: this path reaches the RAW
2764
+ # database, so hiding the proxies alone would not keep a
2765
+ # mid-boot (or failed-boot) rebuild out.
2766
+ unless boot_state == :ready
2767
+ raise UnavailableError, "the local database is not booted"
2768
+ end
2769
+ state = durability
2770
+ if state == :persistent_reader
2771
+ raise ReadOnlyTabError,
2772
+ "reset_local requires the writer tab " \
2773
+ "(this tab is a persistent_reader)"
2774
+ end
2775
+ db = __registered_database(:local)
2776
+ unless db
2777
+ raise UnavailableError, "the local database is not booted"
2778
+ end
2779
+ # Same nesting hazard as wipe: never rebuild into an open
2780
+ # transaction.
2781
+ ensure_not_in_transaction(:local)
2782
+ lockdown = @schema_lockdown
2783
+ if lockdown
2784
+ db.execute("PRAGMA query_only = OFF")
2785
+ begin
2786
+ rebuild_local_table(db, model)
2787
+ revalidate_schema_lockdown(db)
2788
+ rescue => e
2789
+ # The lift is provisional: whatever failed here (a broken
2790
+ # rebuild, an ordinary migration error during revalidation),
2791
+ # the lockdown still stands and SQLite's own write refusal
2792
+ # must stand back up with it -- the raw export path is
2793
+ # SELECT-only by contract.
2794
+ db.execute("PRAGMA query_only = ON") if @schema_lockdown
2795
+ raise e
2796
+ end
2797
+ else
2798
+ rebuild_local_table(db, model)
2799
+ end
2800
+ model.local_table_changed
2801
+ true
2802
+ end
2803
+
2804
+ class << self
2805
+ private def revalidate_schema_lockdown(db)
2806
+ registry = @databases
2807
+ entry = registry ? registry[:local] : nil
2808
+ # @type var models: Array[untyped]
2809
+ models = entry ? entry[1] : []
2810
+ begin
2811
+ i = 0
2812
+ models_size = models.size
2813
+ while i < models_size
2814
+ apply_local_migrations(db, models[i])
2815
+ i += 1
2816
+ end
2817
+ @schema_lockdown = nil
2818
+ puts "[Funicular] local database lockdown lifted"
2819
+ rescue SchemaTooNewError => e
2820
+ engage_schema_lockdown(db, e)
2821
+ end
2822
+ nil
2823
+ end
2824
+ end
2825
+
2826
+ # Test seam: boot wires process-global state; per-file test VMs
2827
+ # unwind it between tests.
2828
+ def self.__reset_boot
2829
+ release_writer_lock
2830
+ @boot_state = :unbooted
2831
+ @durability = :unbooted
2832
+ @local_handle = nil
2833
+ @replica_handle = nil
2834
+ @schema_lockdown = nil
2835
+ @session_epoch = nil
2836
+ @page_metadata = nil
2837
+ @page_metadata_latched = false
2838
+ @local_database_enabled = false
2839
+ @local_database_latched = false
2840
+ @page_epoch_latched = false
2841
+ @session_terminated = false
2842
+ @databases = nil
2843
+ # A put still in flight from the torn-down state must not make
2844
+ # the next terminal step-down wait on it.
2845
+ @persist_inflight = 0
2846
+ cancel_persist_timers
2847
+ __set_snapshot_store(nil)
2848
+ __set_snapshot_identity(nil)
2849
+ nil
2850
+ end
2851
+
2852
+ # ---- replica tables: schema-derived DDL + fingerprint ---------------
2853
+ #
2854
+ # Replica tables mirror server data; their shape is DERIVED from the
2855
+ # server-delivered schema (docs decision 6), never migrated by hand.
2856
+ # The fingerprint is the canonical schema JSON itself, stored in the
2857
+ # meta table and compared by string equality.
2858
+
2859
+ REPLICA_FINGERPRINT_KEY = "replica_fingerprint"
2860
+
2861
+ # CREATE TABLE for one replica model. The id column type follows the
2862
+ # server (:integer -> INTEGER, anything else -> TEXT for UUIDs); a
2863
+ # schema without an id cannot be mirrored by row identity.
2864
+ def self.replica_table_ddl(model)
2865
+ columns = model.local_columns
2866
+ id_type = columns["id"]
2867
+ unless id_type
2868
+ raise ArgumentError,
2869
+ "#{model.table_name}: the server schema has no id attribute; " \
2870
+ "rows cannot be mirrored -- declare storage :ephemeral"
2871
+ end
2872
+ table = validate_identifier(model.table_name)
2873
+ # @type var defs: Array[String]
2874
+ defs = ["\"id\" #{id_type == :integer ? "INTEGER" : "TEXT"} PRIMARY KEY"]
2875
+ names = columns.keys
2876
+ names_size = names.size
2877
+ i = 0
2878
+ while i < names_size
2879
+ name = names[i]
2880
+ unless name == "id"
2881
+ defs << "\"#{validate_identifier(name)}\" " \
2882
+ "#{SQL_TYPES[columns[name]] || "TEXT"}"
2883
+ end
2884
+ i += 1
2885
+ end
2886
+ "CREATE TABLE \"#{table}\" (#{defs.join(", ")})"
2887
+ end
2888
+
2889
+ # The canonical schema JSON: only DDL-affecting data (table names,
2890
+ # column names and types, the id type), tables and columns sorted by
2891
+ # name so hash ordering never leaks in. This string IS the
2892
+ # fingerprint -- no digest (docs decision 6).
2893
+ def self.canonical_replica_schema(models)
2894
+ # @type var tables: Array[untyped]
2895
+ tables = []
2896
+ models_size = models.size
2897
+ i = 0
2898
+ while i < models_size
2899
+ model = models[i]
2900
+ columns = model.local_columns
2901
+ names = columns.keys.sort
2902
+ # @type var cols: Array[untyped]
2903
+ cols = []
2904
+ names_size = names.size
2905
+ j = 0
2906
+ while j < names_size
2907
+ cols << [names[j], columns[names[j]].to_s]
2908
+ j += 1
2909
+ end
2910
+ tables << [model.table_name, cols]
2911
+ i += 1
2912
+ end
2913
+ # Plain array comparison: entries are [table_name, columns], so the
2914
+ # sort is deterministic down to the column definitions without a
2915
+ # comparator block.
2916
+ tables.sort!
2917
+ JSON.generate(["v1", tables])
2918
+ end
2919
+
2920
+ # Bring the replica database to the declared schema. A matching
2921
+ # fingerprint keeps every table and its data; a mismatch (or a fresh
2922
+ # database) drops and recreates ALL replica tables EMPTY -- they
2923
+ # refill on the app's next explicit fetch -- and stores the new
2924
+ # fingerprint. One transaction. Returns true when tables were
2925
+ # (re)built.
2926
+ # The replica lives in its own database -- storage :local tables are
2927
+ # in the OTHER database and can legitimately share names with
2928
+ # replica tables (that is why notify_changed takes a database role).
2929
+ # Only the framework's meta table needs shielding here.
2930
+ def self.build_replica_tables(db, models)
2931
+ # Duplicate checks compare ASCII-lowercased keys: SQLite table
2932
+ # names are case-insensitive even when quoted. SQL statements keep
2933
+ # the original validated spelling (the hash VALUES).
2934
+ # @type var tables: Hash[String, String]
2935
+ tables = {}
2936
+ models_size = models.size
2937
+ i = 0
2938
+ while i < models_size
2939
+ name = guard_reserved_table(validate_identifier(models[i].table_name))
2940
+ key = name.downcase
2941
+ if tables.has_key?(key)
2942
+ raise ArgumentError,
2943
+ "two replica models declare the table \"#{name}\" " \
2944
+ "(SQLite table names are case-insensitive); give one of " \
2945
+ "them another table_name"
2946
+ end
2947
+ tables[key] = name
2948
+ i += 1
2949
+ end
2950
+ fingerprint = canonical_replica_schema(models)
2951
+ stored = read_meta(db, REPLICA_FINGERPRINT_KEY)
2952
+ return false if stored == fingerprint
2953
+ # Drop the UNION of old and new table names: a model REMOVED from
2954
+ # the declared set must not leave its stale table (and data)
2955
+ # behind. The old names come from the stored fingerprint itself --
2956
+ # it IS the canonical schema JSON.
2957
+ old_names = stale_replica_tables(stored)
2958
+ old_size = old_names.size
2959
+ i = 0
2960
+ while i < old_size
2961
+ begin
2962
+ # @type var old_name: String
2963
+ old_name = validate_identifier(old_names[i])
2964
+ # @type var old_key: String
2965
+ old_key = old_name.downcase
2966
+ unless old_key == META_TABLE || tables.has_key?(old_key)
2967
+ tables[old_key] = old_name
2968
+ end
2969
+ rescue ArgumentError
2970
+ # A corrupted meta row must not smuggle SQL into DROP TABLE.
2971
+ end
2972
+ i += 1
2973
+ end
2974
+ db.transaction do
2975
+ keys = tables.keys
2976
+ keys_size = keys.size
2977
+ i = 0
2978
+ while i < keys_size
2979
+ db.execute("DROP TABLE IF EXISTS \"#{tables[keys[i]]}\"")
2980
+ i += 1
2981
+ end
2982
+ i = 0
2983
+ while i < models_size
2984
+ db.execute(replica_table_ddl(models[i]))
2985
+ i += 1
2986
+ end
2987
+ store_meta(db, REPLICA_FINGERPRINT_KEY, fingerprint)
2988
+ end
2989
+ true
2990
+ end
2991
+
2992
+ class << self
2993
+ # The table names recorded in a stored canonical fingerprint; empty
2994
+ # when absent or unparsable (fail-safe: nothing extra to drop).
2995
+ private def stale_replica_tables(stored)
2996
+ # @type var names: Array[String]
2997
+ names = []
2998
+ return names unless stored
2999
+ begin
3000
+ parsed = JSON.parse(stored)
3001
+ rescue
3002
+ return names
3003
+ end
3004
+ return names unless parsed.is_a?(Array)
3005
+ list = parsed[1]
3006
+ return names unless list.is_a?(Array)
3007
+ list_size = list.size
3008
+ i = 0
3009
+ while i < list_size
3010
+ entry = list[i]
3011
+ names << entry[0].to_s if entry.is_a?(Array)
3012
+ i += 1
3013
+ end
3014
+ names
3015
+ end
3016
+ end
3017
+
3018
+ # Apply one server-authoritative row: THE single write-through entry
3019
+ # point (docs decision 5) -- fetch-through, create/update responses,
3020
+ # and future Cable sync all land here. Whole-row INSERT OR REPLACE
3021
+ # through the codec; keys absent from attrs store NULL (the server
3022
+ # row is authoritative, there is no partial merge). Fires the
3023
+ # model's change hook.
3024
+ def self.replica_upsert(db, model, attrs)
3025
+ replica_upsert_row(db, model, attrs)
3026
+ model.local_table_changed
3027
+ true
3028
+ end
3029
+
3030
+ # Batch apply for whole-collection fetches: every row lands -- or,
3031
+ # when any row fails (a malformed value, most likely), NONE does --
3032
+ # inside ONE transaction, and the change hook fires once after the
3033
+ # commit: a 50-row fetch is one event, not fifty.
3034
+ def self.replica_upsert_all(db, model, rows)
3035
+ rows_size = rows.size
3036
+ return true if rows_size == 0
3037
+ db.transaction do
3038
+ i = 0
3039
+ while i < rows_size
3040
+ replica_upsert_row(db, model, rows[i])
3041
+ i += 1
3042
+ end
3043
+ end
3044
+ model.local_table_changed
3045
+ true
3046
+ end
3047
+
3048
+ class << self
3049
+ # NOT a public entry point: writing a row without the change
3050
+ # notification would bypass the apply-path contract. Only
3051
+ # replica_upsert and replica_upsert_all come through here.
3052
+ private def replica_upsert_row(db, model, attrs)
3053
+ columns = model.local_columns
3054
+ table = validate_identifier(model.table_name)
3055
+ names = columns.keys
3056
+ # @type var cols: Array[String]
3057
+ cols = []
3058
+ # @type var marks: Array[String]
3059
+ marks = []
3060
+ # @type var binds: Array[untyped]
3061
+ binds = []
3062
+ id_present = false
3063
+ names_size = names.size
3064
+ i = 0
3065
+ while i < names_size
3066
+ name = names[i]
3067
+ if attrs.has_key?(name)
3068
+ value = attrs[name]
3069
+ elsif attrs.has_key?(name.to_sym)
3070
+ value = attrs[name.to_sym]
3071
+ else
3072
+ value = nil
3073
+ end
3074
+ id_present = true if name == "id" && !value.nil?
3075
+ cols << "\"#{name}\""
3076
+ marks << "?"
3077
+ binds << Codec.encode(columns[name], value)
3078
+ i += 1
3079
+ end
3080
+ unless id_present
3081
+ raise ArgumentError,
3082
+ "replica upsert into #{model.table_name} requires an id; " \
3083
+ "the server row has none"
3084
+ end
3085
+ db.execute("INSERT OR REPLACE INTO \"#{table}\" " \
3086
+ "(#{cols.join(", ")}) VALUES (#{marks.join(", ")})", binds)
3087
+ end
3088
+ end
3089
+
3090
+ # Remove one mirrored row (write-through destroy). Notifies only
3091
+ # when the row existed; RETURNING keeps the check inside the one
3092
+ # statement (no changes() race).
3093
+ def self.replica_delete(db, model, id)
3094
+ table = validate_identifier(model.table_name)
3095
+ rows = db.execute(
3096
+ "DELETE FROM \"#{table}\" WHERE \"id\" = ? RETURNING \"id\"",
3097
+ [Codec.encode(model.local_columns["id"], id)])
3098
+ deleted = !rows.empty?
3099
+ model.local_table_changed if deleted
3100
+ deleted
3101
+ end
3102
+
3103
+ class << self
3104
+ private def quoted_list(columns)
3105
+ # @type var quoted: Array[String]
3106
+ quoted = []
3107
+ i = 0
3108
+ while i < columns.size
3109
+ quoted << "\"#{columns[i]}\""
3110
+ i += 1
3111
+ end
3112
+ quoted.join(", ")
3113
+ end
3114
+ end
3115
+ end
3116
+ end