funicular 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +486 -1
- data/demo/local_notes.html +207 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +227 -12
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +58 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +264 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/sig_tags_test.rb +30 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/minitest/view_context_test.rb +15 -15
- data/mrbgem.rake +2 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +226 -24
- data/mrblib/db.rb +3116 -0
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/form_builder.rb +10 -10
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +122 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +25 -4
- data/sig/db.rbs +328 -0
- data/sig/error_boundary.rbs +4 -4
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +19 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +23 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
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
|