funicular 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/model.rb CHANGED
@@ -1,3 +1,7 @@
1
+ # On the browser this is picoruby-uri; under CRuby SSR it is the stdlib URI.
2
+ # Both encode_www_form the same way (space -> "+", byte-wise %XX).
3
+ require 'uri'
4
+
1
5
  module Funicular
2
6
  class Model
3
7
  include Validations
@@ -8,9 +12,31 @@ module Funicular
8
12
  attr_accessor :schema, :endpoints
9
13
  end
10
14
 
15
+ # Every Model subclass registers itself at definition time: the
16
+ # boot (docs decision 19) needs the full declared set -- local
17
+ # models for migrations, replica models for the schema-derived
18
+ # DDL -- without asking the app to enumerate it.
19
+ def self.inherited(subclass)
20
+ Funicular::Model.__register_model(subclass)
21
+ end
22
+
23
+ def self.__register_model(subclass)
24
+ registry = (@registered_models ||= []) # steep:ignore UnannotatedEmptyCollection
25
+ registry << subclass
26
+ nil
27
+ end
28
+
29
+ def self.__registered_models
30
+ models = Funicular::Model.instance_variable_get(:@registered_models)
31
+ models || []
32
+ end
33
+
11
34
  def self.load_schema(schema_data)
12
35
  @schema = schema_data["attributes"]
13
36
  @endpoints = schema_data["endpoints"]
37
+ # Replica column metadata derives from the schema; drop any cache.
38
+ @local_columns = nil
39
+ __assert_no_association_conflict(@schema.keys)
14
40
 
15
41
  # Generate attr_accessor dynamically based on schema
16
42
  @schema.each do |name, config|
@@ -40,6 +66,10 @@ module Funicular
40
66
  next unless rules.is_a?(Hash)
41
67
  rules.each do |kind, opts|
42
68
  options = normalize_validation_options(kind, opts)
69
+ # nil means the options could not be materialized on this runtime
70
+ # (e.g. a format regex the JS RegExp engine rejects): drop that one
71
+ # validator instead of failing the whole schema load.
72
+ next if options.nil?
43
73
  add_schema_validator(attribute, kind, options)
44
74
  end
45
75
  end
@@ -56,54 +86,876 @@ module Funicular
56
86
  bits = 0
57
87
  bits |= Regexp::IGNORECASE if flags.include?("i")
58
88
  bits |= Regexp::MULTILINE if flags.include?("m")
59
- { with: Regexp.new(opts["with"], bits) }
89
+ begin
90
+ # Annotated so the record type {with: Regexp} does not reject
91
+ # the boolean flags added below.
92
+ normalized = { with: Regexp.new(opts["with"], bits) } #: Hash[Symbol, untyped]
93
+ normalized[:allow_nil] = true if opts["allow_nil"]
94
+ normalized[:allow_blank] = true if opts["allow_blank"]
95
+ normalized
96
+ rescue => e
97
+ # Schema regex translation is best-effort; a source this engine
98
+ # cannot compile must not take down the whole boot sequence.
99
+ # Name the pattern (truncated) so the offending model/attribute
100
+ # can be found among many schemas loading at boot.
101
+ source = opts["with"].to_s
102
+ source = "#{source[0, 60]}..." if 60 < source.length
103
+ puts "[Funicular] Skipping format validator (#{e.class}: #{e.message}; pattern: #{source})"
104
+ nil
105
+ end
60
106
  else
61
107
  opts
62
108
  end
63
109
  end
64
110
 
111
+ # ---- local-database declaration DSL (docs/local_database.md) --------
112
+ #
113
+ # storage :replica (default) server data, mirrored into the replica
114
+ # storage :ephemeral REST only, no local table
115
+ # storage :local do ... end client-only table built by migrate blocks
116
+ #
117
+ # The migrate blocks are only RECORDED at class-definition time (their
118
+ # version rules validated); they execute against the local database at
119
+ # boot, in the migration runner.
120
+
121
+ def self.storage(kind, &block)
122
+ # refresh is a replica-only axis: a local table has no server to
123
+ # refresh from, and an ephemeral model has no table at all. The
124
+ # check lives in BOTH declarations because either can come
125
+ # first in the class body.
126
+ # The message reads the kind through an unnarrowed alias: inside
127
+ # the branch below the comparisons leave `kind` with no type
128
+ # steep will call #inspect on.
129
+ # @type var declared: Symbol
130
+ declared = kind
131
+ if @refresh_mode && (kind == :local || kind == :ephemeral)
132
+ raise ArgumentError,
133
+ "storage #{declared.inspect} cannot follow a refresh " \
134
+ "declaration (refresh applies to replica models only)"
135
+ end
136
+ # A storage change invalidates cached column metadata.
137
+ @local_columns = nil
138
+ if kind == :local
139
+ unless block
140
+ raise ArgumentError,
141
+ "storage :local requires a block with migrate declarations"
142
+ end
143
+ @storage_kind = kind
144
+ @local_migrations = []
145
+ @collecting_migrations = true
146
+ begin
147
+ block.call
148
+ ensure
149
+ @collecting_migrations = false
150
+ end
151
+ validate_local_migrations
152
+ elsif kind == :replica || kind == :ephemeral
153
+ if block
154
+ raise ArgumentError, "only storage :local takes a block"
155
+ end
156
+ @storage_kind = kind
157
+ else
158
+ raise ArgumentError,
159
+ "storage must be :replica, :ephemeral, or :local, got #{kind.inspect}"
160
+ end
161
+ kind
162
+ end
163
+
164
+ def self.storage_kind
165
+ @storage_kind || :replica
166
+ end
167
+
168
+ def self.replica?
169
+ storage_kind == :replica
170
+ end
171
+
172
+ def self.ephemeral?
173
+ storage_kind == :ephemeral
174
+ end
175
+
176
+ def self.local?
177
+ storage_kind == :local
178
+ end
179
+
180
+ # Record one numbered migration block. Only valid inside storage :local.
181
+ def self.migrate(version, reset: false, &block)
182
+ migrations = @local_migrations
183
+ unless @collecting_migrations && migrations
184
+ raise ArgumentError,
185
+ "migrate must be declared inside a storage :local block"
186
+ end
187
+ unless version.is_a?(Integer) && 1 <= version
188
+ raise ArgumentError,
189
+ "migrate version must be a positive Integer, got #{version.inspect}"
190
+ end
191
+ unless block
192
+ raise ArgumentError, "migrate #{version} requires a block"
193
+ end
194
+ migrations << { version: version, reset: reset, block: block }
195
+ end
196
+
197
+ def self.local_migrations
198
+ @local_migrations
199
+ end
200
+
201
+ # Baseline rules (docs): the first retained block is version 1 unless
202
+ # it is `reset: true` (then any positive version); contiguous after
203
+ # that. A gap means a deleted non-baseline block -- fail at class eval.
204
+ def self.validate_local_migrations
205
+ migrations = @local_migrations
206
+ if migrations.nil? || migrations.empty?
207
+ raise ArgumentError, "storage :local requires at least one migrate block"
208
+ end
209
+ first = migrations[0]
210
+ unless first[:version] == 1 || first[:reset]
211
+ raise ArgumentError,
212
+ "the first migrate block must be version 1 (or reset: true), " \
213
+ "got #{first[:version]}"
214
+ end
215
+ i = 1
216
+ while i < migrations.size
217
+ expected = migrations[i - 1][:version] + 1
218
+ unless migrations[i][:version] == expected
219
+ raise ArgumentError,
220
+ "migrate versions must be contiguous: expected #{expected}, " \
221
+ "got #{migrations[i][:version]}"
222
+ end
223
+ i += 1
224
+ end
225
+ end
226
+
227
+ # v1 implements only the default :manual (freshness is explicit fetch);
228
+ # :auto and :live are reserved and rejected at class-definition time.
229
+ def self.refresh(mode)
230
+ kind = @storage_kind
231
+ if kind == :local || kind == :ephemeral
232
+ raise ArgumentError,
233
+ "refresh does not apply to storage #{storage_kind.inspect} " \
234
+ "(refresh applies to replica models only)"
235
+ end
236
+ if mode == :auto
237
+ raise NotImplementedError,
238
+ "refresh :auto is not yet supported (v1 is :manual only)"
239
+ end
240
+ if mode == :live
241
+ raise NotImplementedError,
242
+ "refresh :live is not yet supported (v1 is :manual only)"
243
+ end
244
+ unless mode == :manual
245
+ raise ArgumentError, "refresh must be :manual, got #{mode.inspect}"
246
+ end
247
+ @refresh_mode = mode
248
+ end
249
+
250
+ def self.refresh_mode
251
+ @refresh_mode || :manual
252
+ end
253
+
254
+ # ---- associations (docs/local_database.md, "Associations") ----------
255
+ #
256
+ # Local-query sugar over the <name>_id convention, and nothing more:
257
+ #
258
+ # post.user -> User.local.find_by(id: post.user_id)
259
+ # post.comments -> Comment.local.where(post_id: post.id)
260
+ #
261
+ # Readers only. The foreign key is assigned the plain way
262
+ # (post.user_id = user.id), so there is no second, hidden path into
263
+ # the column and no dirty-tracking special case.
264
+
265
+ def self.belongs_to(name, class_name: nil, foreign_key: nil, **rest)
266
+ __reject_association_options(:belongs_to, name, rest)
267
+ key = name.to_sym
268
+ target = (class_name || __camelize(key.to_s)).to_s
269
+ fk = (foreign_key || "#{key}_id").to_s
270
+ __register_association(key)
271
+ define_method(key) do
272
+ # @type self: Model
273
+ value = send(fk)
274
+ if value.nil?
275
+ # No foreign key, no target row -- and no query to run.
276
+ nil
277
+ else
278
+ self.class.__association_target(key, target).local.find_by(id: value)
279
+ end
280
+ end
281
+ nil
282
+ end
283
+
284
+ def self.has_many(name, class_name: nil, foreign_key: nil, **rest)
285
+ __reject_association_options(:has_many, name, rest)
286
+ key = name.to_sym
287
+ target = (class_name || __camelize(__singularize(key.to_s))).to_s
288
+ fk = (foreign_key || "#{demodulized_snake_name}_id").to_s
289
+ __register_association(key)
290
+ define_method(key) do
291
+ # @type self: Model
292
+ # An unsaved parent owns nothing: the empty-array condition
293
+ # compiles to 1=0, where a nil would read as IS NULL and hand
294
+ # this record every orphan row in the table.
295
+ none = [] #: Array[untyped]
296
+ value = id.nil? ? none : id
297
+ self.class.__association_target(key, target).local.where(fk => value)
298
+ end
299
+ nil
300
+ end
301
+
302
+ def self.__reject_association_options(kind, name, rest)
303
+ return nil if rest.empty?
304
+ raise ArgumentError,
305
+ "#{kind} #{name.to_sym.inspect}: #{rest.keys.inspect} not supported " \
306
+ "in v1 (only class_name: and foreign_key:; through, eager loading, " \
307
+ "and polymorphic associations are not implemented)"
308
+ end
309
+
310
+ # The registry exists for two readers: the memoized target class,
311
+ # and the column/attribute collision guard.
312
+ def self.__register_association(key)
313
+ registry = (@associations ||= {}) # steep:ignore UnannotatedEmptyCollection
314
+ # Last-one-wins would be the same silent shrug as ignoring
315
+ # through:: the reader defined first is simply gone.
316
+ if registry.has_key?(key)
317
+ raise ArgumentError,
318
+ "#{self}: the association #{key.inspect} is already declared"
319
+ end
320
+ __assert_association_name_free(key)
321
+ registry[key] = { klass: nil }
322
+ nil
323
+ end
324
+
325
+ # Unlike every other generated accessor, an association reader is
326
+ # defined unconditionally -- so `has_many :errors` would replace
327
+ # Validations#errors, and valid? (which calls errors.clear) would
328
+ # die on a Relation, at neither declaration nor first read.
329
+ #
330
+ # The reserved set is Funicular::Model's OWN instance API: a
331
+ # subclass's column accessors live on the subclass, so they can
332
+ # never appear here, whenever they are generated. Subtracting
333
+ # Object's keeps generic names (hash, send) out of it. A method the
334
+ # model itself hand-wrote is deliberately NOT covered -- catching
335
+ # it would only work when the `def` precedes the declaration.
336
+ def self.__assert_association_name_free(key)
337
+ # One fixed set per process; the framework's API stops changing
338
+ # once mrblib is loaded, well before any app model declares.
339
+ reserved = Funicular::Model.instance_variable_get(:@reserved_names) ||
340
+ Funicular::Model.instance_variable_set(
341
+ :@reserved_names,
342
+ Funicular::Model.instance_methods - Object.instance_methods)
343
+ return nil unless reserved.include?(key)
344
+ raise ArgumentError,
345
+ "#{self}: the association #{key.inspect} would replace " \
346
+ "Funicular::Model##{key}; pick another name"
347
+ end
348
+
349
+ def self.__associations
350
+ @associations || {}
351
+ end
352
+
353
+ # Resolved lazily, at first read: model files load in sorted filename
354
+ # order, so `belongs_to :user` in post.rb must not demand the User
355
+ # constant while user.rb is still unloaded. Only successes are
356
+ # memoized -- a class that loads later still resolves.
357
+ def self.__association_target(key, class_name)
358
+ entry = __associations[key]
359
+ cached = entry && entry[:klass]
360
+ return cached if cached
361
+ klass = __resolve_association_constant(key, class_name)
362
+ entry[:klass] = klass if entry
363
+ klass
364
+ end
365
+
366
+ # Convention-derived target names never carry "::", so the walk is
367
+ # here for class_name: strings alone.
368
+ def self.__resolve_association_constant(key, class_name)
369
+ parts = class_name.split("::")
370
+ # @type var owner: untyped
371
+ owner = Object
372
+ i = 0
373
+ while i < parts.size
374
+ part = parts[i].to_sym
375
+ # inherit: false. A class used as a namespace inherits from
376
+ # Object, so the default lookup would answer "Admin::User" with
377
+ # the top-level ::User when Admin carries no User of its own --
378
+ # a typo resolving to a DIFFERENT model, in the one path whose
379
+ # job is to fail clearly. const_get takes no such argument here
380
+ # (picoruby: one argument only), but it does not need one: an
381
+ # own constant wins the lookup, and the check below is what
382
+ # decides whether there is one.
383
+ unless owner.is_a?(Module) && owner.const_defined?(part, false)
384
+ raise NameError, __unresolvable_association(key, class_name)
385
+ end
386
+ owner = owner.const_get(part)
387
+ i += 1
388
+ end
389
+ # A module or a plain value has no `.local` behind it; same
390
+ # verdict, same message.
391
+ unless owner.is_a?(Class)
392
+ raise NameError, __unresolvable_association(key, class_name)
393
+ end
394
+ owner
395
+ end
396
+
397
+ def self.__unresolvable_association(key, class_name)
398
+ "#{self}: association #{key.inspect} cannot resolve the model " \
399
+ "class #{class_name}; declare class_name:, or make sure the model " \
400
+ "is one the client carries"
401
+ end
402
+
403
+ # A column (or REST attribute) named like a declared association:
404
+ # the association reader would shadow it silently while the
405
+ # generated writer kept writing the column. Raised where the two
406
+ # first meet -- the accessor generation, which is the first instance
407
+ # path on local models and the schema load on the others.
408
+ def self.__assert_no_association_conflict(names)
409
+ registry = @associations
410
+ return nil unless registry
411
+ i = 0
412
+ while i < names.size
413
+ name = names[i]
414
+ if registry.has_key?(name.to_sym)
415
+ raise ArgumentError,
416
+ "#{self}: the association #{name.to_sym.inspect} and the " \
417
+ "attribute of the same name would shadow each other; rename one"
418
+ end
419
+ i += 1
420
+ end
421
+ nil
422
+ end
423
+
424
+ # Reader and override in one: `table_name` returns the table name
425
+ # (naive pluralization of the class name: +s, y->ies),
426
+ # `table_name "things"` overrides it.
427
+ def self.table_name(explicit = nil)
428
+ if explicit
429
+ @table_name = explicit.to_s
430
+ else
431
+ @table_name ||= derive_table_name
432
+ end
433
+ end
434
+
435
+ def self.derive_table_name
436
+ __pluralize(demodulized_snake_name)
437
+ end
438
+
439
+ # "Admin::BlogPost" -> "blog_post". The singular half of
440
+ # derive_table_name; has_many reuses it for the foreign key.
441
+ def self.demodulized_snake_name
442
+ parts = to_s.split("::")
443
+ base = parts[parts.size - 1] || ""
444
+ snake = ""
445
+ i = 0
446
+ while i < base.length
447
+ c = base.getbyte(i)
448
+ if c && 65 <= c && c <= 90 # A-Z
449
+ snake += "_" unless i == 0
450
+ snake += (c + 32).chr
451
+ else
452
+ char = base[i]
453
+ snake += char if char
454
+ end
455
+ i += 1
456
+ end
457
+ snake
458
+ end
459
+
460
+ def self.__pluralize(name)
461
+ if name.end_with?("y")
462
+ "#{name[0, name.length - 1]}ies"
463
+ else
464
+ "#{name}s"
465
+ end
466
+ end
467
+
468
+ # The exact inverse of __pluralize. Irregular names are the caller's
469
+ # job, through class_name: -- the same deal table_name offers.
470
+ def self.__singularize(name)
471
+ if name.end_with?("ies")
472
+ "#{name[0, name.length - 3]}y"
473
+ elsif name.end_with?("s")
474
+ name[0, name.length - 1].to_s
475
+ else
476
+ name
477
+ end
478
+ end
479
+
480
+ def self.__camelize(name)
481
+ parts = name.split("_")
482
+ out = ""
483
+ i = 0
484
+ while i < parts.size
485
+ part = parts[i]
486
+ head = part[0]
487
+ if head
488
+ out += head.upcase
489
+ out += part[1, part.length - 1].to_s
490
+ end
491
+ i += 1
492
+ end
493
+ out
494
+ end
495
+
496
+ # The marked local/cache view (source-of-truth contract): a Relation
497
+ # over the whole local table. Ephemeral models have no table behind it.
498
+ def self.local
499
+ if ephemeral?
500
+ raise Funicular::DB::NoTableError,
501
+ "#{to_s} is storage :ephemeral; it has no local table"
502
+ end
503
+ Relation.new(self)
504
+ end
505
+
506
+ # ---- Relation protocol (see mrblib/relation.rb) ---------------------
507
+
508
+ # Column name -> declared type. For replica models this derives from
509
+ # the server schema: binary attributes never reach the replica and the
510
+ # id type follows the server. Local models fold their migrate blocks
511
+ # (pure metadata; works before boot).
512
+ def self.local_columns
513
+ if ephemeral?
514
+ raise Funicular::DB::NoTableError,
515
+ "#{to_s} is storage :ephemeral; it has no local table"
516
+ end
517
+ cols = @local_columns
518
+ return cols if cols
519
+ if local?
520
+ # The fold runs HERE, lazily, not at class-definition time: the
521
+ # docs promise migrate blocks are only recorded at class eval.
522
+ # Any instance path (new/create/find) lands here first, so the
523
+ # accessors exist before they can be called.
524
+ folded = Funicular::DB.fold_local_columns(self)
525
+ define_local_accessors(folded)
526
+ @local_columns = folded
527
+ else
528
+ @local_columns = derive_replica_columns
529
+ end
530
+ end
531
+
532
+ def self.derive_replica_columns
533
+ sch = @schema
534
+ unless sch
535
+ raise Funicular::DB::UnavailableError,
536
+ "#{to_s}: no schema loaded; replica columns derive from the " \
537
+ "server schema at boot"
538
+ end
539
+ # @type var derived: Hash[String, Symbol]
540
+ derived = {}
541
+ names = sch.keys
542
+ i = 0
543
+ while i < names.size
544
+ attr_name = names[i]
545
+ type = (sch[attr_name]["type"] || "string").to_sym
546
+ derived[attr_name] = type unless type == :binary
547
+ i += 1
548
+ end
549
+ derived
550
+ end
551
+
552
+ # The handle local queries run against: the guarded proxy for this
553
+ # model's storage role, installed by Funicular::DB.boot. Before the boot
554
+ # every materializer fails loud (UnavailableError -- SSR included).
555
+ # Storage-local models also encounter the SchemaTooNew lockdown here,
556
+ # the funnel every model-level local operation passes through.
557
+ def self.local_db
558
+ Funicular::DB.__model_local_db(self)
559
+ end
560
+
561
+ # The handle REST write-through applies replica rows to: the
562
+ # guarded replica proxy from DB.boot -- raw connections are never
563
+ # exposed globally. While it is nil (not booted), write-through
564
+ # stays inert and REST works standalone.
565
+ def self.replica_db
566
+ Funicular::DB.__model_replica_db
567
+ end
568
+
569
+ # Drop this table and rebuild it from its migrate baseline (docs
570
+ # decision 7): the programmatic reset for one client-only table.
571
+ # Writer (or volatile) tab only; lifts a SchemaTooNew lockdown
572
+ # when the whole declared set passes again afterwards.
573
+ def self.reset_local
574
+ unless local?
575
+ raise Funicular::DB::NoTableError,
576
+ "#{to_s} is not storage :local; reset_local rebuilds " \
577
+ "client-only tables"
578
+ end
579
+ Funicular::DB.reset_local_table(self)
580
+ end
581
+
582
+ def self.build_from_local(attrs)
583
+ record = new({})
584
+ record.__hydrate_local(attrs)
585
+ record
586
+ end
587
+
588
+ # Called by Relation#delete_all, the local CRUD writers, and the
589
+ # replica apply path after a framework-managed write: the change-
590
+ # event bus takes it from here (snapshot scheduling joins in at
591
+ # boot).
592
+ def self.local_table_changed
593
+ Funicular::DB.notify_changed(self)
594
+ end
595
+
596
+ # The public change-subscription primitive (docs decision 10):
597
+ # watch's Relation-only contract covers lists; for hashes, counts,
598
+ # or raw-SQL-derived state, subscribe here and patch state in the
599
+ # handler. Returns a subscription for off_change.
600
+ def self.on_change(&block)
601
+ unless block
602
+ raise ArgumentError, "on_change requires a block"
603
+ end
604
+ if ephemeral?
605
+ raise Funicular::DB::NoTableError,
606
+ "#{to_s} is storage :ephemeral; it has no local table to watch"
607
+ end
608
+ Funicular::DB.__ensure_local_database_enabled(:on_change)
609
+ Funicular::DB.subscribe(replica? ? :replica : :local,
610
+ table_name, &block)
611
+ end
612
+
613
+ def self.off_change(subscription)
614
+ Funicular::DB.unsubscribe(subscription)
615
+ end
616
+
617
+ # ---- write-through (docs decision 5) --------------------------------
618
+ # Successful REST responses mirror rows into the replica through the
619
+ # single apply entry point, BEFORE user callbacks run. Inert on
620
+ # non-replica models and until DB.boot installs the replica handle.
621
+
622
+ def self.__write_through_upsert(attrs)
623
+ return unless replica?
624
+ db = replica_db
625
+ return unless db
626
+ Funicular::DB.replica_upsert(db, self, attrs)
627
+ end
628
+
629
+ def self.__write_through_upsert_all(rows)
630
+ return unless replica?
631
+ db = replica_db
632
+ return unless db
633
+ Funicular::DB.replica_upsert_all(db, self, rows)
634
+ end
635
+
636
+ def self.__write_through_delete(id)
637
+ return unless replica?
638
+ db = replica_db
639
+ return unless db
640
+ Funicular::DB.replica_delete(db, self, id)
641
+ end
642
+
643
+ # Generate attribute readers/writers from the migrate fold, mirroring
644
+ # what load_schema does from the REST schema. Methods the model class
645
+ # ALREADY defines (a hand-written reader like `def title`) are
646
+ # preserved: only the missing half of each accessor pair is
647
+ # generated. The snapshot is taken before anything is generated, so
648
+ # our own accessors never mask a later regeneration.
649
+ def self.define_local_accessors(columns)
650
+ existing = instance_methods
651
+ names = columns.keys
652
+ __assert_no_association_conflict(names)
653
+ names_size = names.size
654
+ i = 0
655
+ while i < names_size
656
+ name = names[i]
657
+ # One method call per column: the writer's closure must capture
658
+ # its own `name` binding. Creating it inside this while body
659
+ # would share ONE variable across every writer (unlike an each
660
+ # block parameter), leaving all of them bound to the last column.
661
+ define_local_accessor(name, existing) unless name == "id"
662
+ i += 1
663
+ end
664
+ end
665
+
666
+ # Writers track dirtiness against @local_baseline -- the values as
667
+ # last persisted/loaded -- not against the previous assignment, so
668
+ # assigning a value BACK cancels the change and the documented
669
+ # "update with no actual changes is a no-op" holds across
670
+ # intermediate assignments. A hand-written writer is kept and
671
+ # WRAPPED: it runs first, then the resulting ivar (its normalized
672
+ # value) feeds the same tracking -- otherwise custom writers would
673
+ # silently opt out of dirty tracking and update would no-op.
674
+ def self.define_local_accessor(name, existing)
675
+ attr_reader name.to_sym unless existing.include?(name.to_sym)
676
+ custom = "__custom_#{name}="
677
+ return if existing.include?(custom.to_sym)
678
+ if existing.include?("#{name}=".to_sym)
679
+ alias_method custom, "#{name}="
680
+ define_method("#{name}=") do |value|
681
+ # @type self: Model
682
+ old = instance_variable_get("@#{name}")
683
+ send(custom, value)
684
+ __track_local_change(name, instance_variable_get("@#{name}"), old)
685
+ end
686
+ else
687
+ define_method("#{name}=") do |value|
688
+ # @type self: Model
689
+ old = instance_variable_get("@#{name}")
690
+ instance_variable_set("@#{name}", value)
691
+ __track_local_change(name, value, old)
692
+ end
693
+ end
694
+ end
695
+
696
+ # Merge bare keywords over the positional attrs hash so a keyword
697
+ # wins per ATTRIBUTE, not per literal key: initialize reads string
698
+ # keys first, so the keyword's string-keyed twin must go away too.
699
+ def self.merge_keyword_attrs(attrs, kw)
700
+ return attrs if kw.empty?
701
+ merged = attrs.merge(kw)
702
+ keys = kw.keys
703
+ keys_size = keys.size
704
+ i = 0
705
+ while i < keys_size
706
+ merged.delete(keys[i].to_s)
707
+ i += 1
708
+ end
709
+ merged
710
+ end
711
+
712
+ # Synchronous, validated create for storage :local (docs, "Local
713
+ # models"): returns the instance -- persisted with its assigned id,
714
+ # or unsaved (id nil) with errors when validation fails.
715
+ def self.local_create(attrs = {})
716
+ unless local?
717
+ if replica?
718
+ raise Funicular::DB::ReplicaWriteError,
719
+ "#{to_s}.local_create is not available on replica models; " \
720
+ "the server owns replica rows (use #{to_s}.create)"
721
+ end
722
+ raise Funicular::DB::NoTableError,
723
+ "#{to_s} has no local table; local_create is available only on " \
724
+ "storage :local models"
725
+ end
726
+ record = new(attrs)
727
+ return record unless record.valid?
728
+ record.__local_insert
729
+ record
730
+ end
731
+
732
+ # ---- bare-class alias (source-of-truth contract) ---------------------
733
+ # On storage :local models the bare class IS the local view, so the
734
+ # ActiveRecord-style query methods hang off it directly. On other
735
+ # storage kinds they do not exist -- the error points at `.local`.
736
+
737
+ def self.local_query(method_name)
738
+ unless local?
739
+ raise NoMethodError,
740
+ "#{to_s}.#{method_name} only exists on storage :local models; " \
741
+ "the marked local view is #{to_s}.local.#{method_name}"
742
+ end
743
+ local
744
+ end
745
+
746
+ def self.where(conditions = nil, *binds)
747
+ local_query("where").where(conditions, *binds)
748
+ end
749
+
750
+ def self.order(*args)
751
+ local_query("order").order(*args)
752
+ end
753
+
754
+ def self.limit(n)
755
+ local_query("limit").limit(n)
756
+ end
757
+
758
+ def self.offset(n)
759
+ local_query("offset").offset(n)
760
+ end
761
+
762
+ def self.count
763
+ local_query("count").count
764
+ end
765
+
766
+ def self.first
767
+ local_query("first").first
768
+ end
769
+
770
+ def self.exists?
771
+ local_query("exists?").exists?
772
+ end
773
+
774
+ def self.find_by(conditions)
775
+ local_query("find_by").find_by(conditions)
776
+ end
777
+
778
+ def self.delete_all
779
+ local_query("delete_all").delete_all
780
+ end
781
+
65
782
  def initialize(attributes = {})
66
783
  @changed_attributes = {}
67
- # Set attributes based on schema
68
- self.class.schema.each do |name, config|
69
- value = attributes[name] || attributes[name.to_sym]
70
- instance_variable_set("@#{name}", value)
784
+ # Attribute names come from the REST schema, or from the migrate
785
+ # fold on storage :local models (which have no REST schema).
786
+ # Key-presence lookups, not `||`: a string-keyed false (boolean
787
+ # columns) must not fall through to the symbol key and come back
788
+ # nil.
789
+ klass = self.class
790
+ is_local = klass.local?
791
+ names = is_local ? klass.local_columns.keys : klass.schema.keys
792
+ # Which attributes were EXPLICITLY given (even as nil) is recorded:
793
+ # the local INSERT path must distinguish "omitted, apply the SQL
794
+ # DEFAULT" from "explicit nil, bind NULL".
795
+ # @type var provided: Hash[String, bool]
796
+ provided = {}
797
+ names_size = names.size
798
+ i = 0
799
+ while i < names_size
800
+ name = names[i]
801
+ sym = name.to_sym
802
+ if attributes.has_key?(name)
803
+ value = attributes[name]
804
+ provided[name] = true
805
+ elsif attributes.has_key?(sym)
806
+ value = attributes[sym]
807
+ provided[name] = true
808
+ else
809
+ value = nil
810
+ end
811
+ if is_local && provided[name] && !(name == "id")
812
+ # User input goes through the writer, so a hand-written
813
+ # normalizing writer applies on create exactly as on update.
814
+ # Hydration from the database does NOT come through here (see
815
+ # build_from_local / __hydrate_local): stored values are
816
+ # already normalized.
817
+ send("#{name}=", value)
818
+ else
819
+ unless is_local || value.nil?
820
+ # REST values pass through the shared codec, so JSON strings
821
+ # and 1/0 become the same Ruby types local queries return
822
+ # (docs decision 8: Post.all and Post.local.find agree).
823
+ value = Funicular::DB::Codec.decode(
824
+ klass.rest_attribute_type(name), value)
825
+ end
826
+ instance_variable_set("@#{name}", value)
827
+ end
828
+ i += 1
829
+ end
830
+ @provided_attributes = provided
831
+ end
832
+
833
+ # The schema-declared type of a REST attribute (nil when unknown);
834
+ # feeds the shared codec on the REST side.
835
+ def self.rest_attribute_type(name)
836
+ sch = @schema
837
+ return nil unless sch
838
+ config = sch[name]
839
+ return nil unless config
840
+ (config["type"] || "string").to_sym
841
+ end
842
+
843
+ # A record not yet in the local table (docs: "a new record is one
844
+ # whose id is nil; create assigns the id from the inserted row").
845
+ def new_record?
846
+ @id.nil?
847
+ end
848
+
849
+ # Shared dirty tracking behind every generated/wrapped local writer.
850
+ def __track_local_change(name, value, old)
851
+ @changed_attributes ||= {} # steep:ignore UnannotatedEmptyCollection
852
+ baseline = @local_baseline
853
+ if baseline && baseline.has_key?(name)
854
+ if value == baseline[name]
855
+ @changed_attributes.delete(name)
856
+ else
857
+ @changed_attributes[name] = value
858
+ end
859
+ elsif !(value == old)
860
+ # No baseline yet (a hand-built record): previous-value compare.
861
+ @changed_attributes[name] = value
71
862
  end
72
863
  end
73
864
 
74
865
  def self.all(params = {}, &block)
866
+ if local?
867
+ # The bare class is an alias for .local: Draft.all IS the
868
+ # whole-table Relation, and there is no REST side to call.
869
+ if block
870
+ raise ArgumentError,
871
+ "#{to_s}.all takes no block on storage :local (no REST side)"
872
+ end
873
+ unless params.nil? || params.empty?
874
+ raise ArgumentError,
875
+ "#{to_s}.all takes no params on storage :local (no REST side)"
876
+ end
877
+ return local
878
+ end
75
879
  endpoint = @endpoints["all"]
76
880
  return unless endpoint
77
881
 
78
- HTTP.get(endpoint["path"]) do |response|
79
- if response.error?
882
+ path = endpoint["path"]
883
+ if params && !params.empty?
884
+ path = "#{path}?#{URI.encode_www_form(params)}"
885
+ end
886
+
887
+ # A wipe between issue and response makes the response stale: it
888
+ # is discarded, never applied -- a logout can never resurrect the
889
+ # previous session's rows (docs decision 17).
890
+ generation = Funicular::DB.mutation_generation
891
+ HTTP.get(path) do |response|
892
+ if Funicular::DB.stale_generation?(generation)
893
+ block.call(nil, Funicular::DB.stale_response_error) if block
894
+ elsif response.error?
80
895
  block.call(nil, response.error_message) if block
81
896
  else
82
- instances = response.data.map { |attrs| new(attrs) }
897
+ rows = response.data
898
+ rows_size = rows.size
899
+ # Fetch-through (docs decision 5): the whole collection lands
900
+ # in the replica -- one transaction, one change event -- before
901
+ # anything else sees it.
902
+ __write_through_upsert_all(rows)
903
+ # @type var instances: Array[Model]
904
+ instances = []
905
+ i = 0
906
+ while i < rows_size
907
+ instances << new(rows[i])
908
+ i += 1
909
+ end
83
910
  block.call(instances, nil) if block
84
911
  end
85
912
  end
86
913
  end
87
914
 
88
915
  def self.find(id = nil, endpoint_name: "find", model_class: nil, &block)
916
+ if local?
917
+ if block
918
+ raise ArgumentError,
919
+ "#{to_s}.find is synchronous on storage :local and takes no block"
920
+ end
921
+ return local.find(id)
922
+ end
89
923
  endpoint = @endpoints[endpoint_name]
90
924
  return unless endpoint
91
925
 
92
926
  path = endpoint["path"]
93
927
  path = path.gsub(":id", id.to_s) if id
94
928
 
929
+ generation = Funicular::DB.mutation_generation
95
930
  HTTP.get(path) do |response|
96
- if response.error?
931
+ if Funicular::DB.stale_generation?(generation)
932
+ block.call(nil, Funicular::DB.stale_response_error) if block
933
+ elsif response.error?
97
934
  block.call(nil, response.error_message) if block
98
935
  else
99
936
  klass = model_class || self
937
+ klass.__write_through_upsert(response.data)
100
938
  instance = klass.new(response.data)
101
939
  block.call(instance, nil) if block
102
940
  end
103
941
  end
104
942
  end
105
943
 
106
- def self.create(attrs, model_class: nil, &block)
944
+ # attrs may be a braced Hash (arrives positionally), bare keywords,
945
+ # or both -- keywords are merged in and win on the same key. The
946
+ # model_class: keyword is reserved for REST models only; on
947
+ # storage :local it is an ordinary attribute, so a column may be
948
+ # named model_class.
949
+ def self.create(attrs = {}, **kw, &block)
950
+ if local?
951
+ if block
952
+ raise ArgumentError,
953
+ "#{to_s}.create is synchronous on storage :local and takes no block"
954
+ end
955
+ return local_create(merge_keyword_attrs(attrs, kw))
956
+ end
957
+ model_class = kw.delete(:model_class)
958
+ attrs = merge_keyword_attrs(attrs, kw)
107
959
  endpoint = @endpoints["create"]
108
960
  return unless endpoint
109
961
 
@@ -114,11 +966,15 @@ module Funicular
114
966
  return
115
967
  end
116
968
 
969
+ generation = Funicular::DB.mutation_generation
117
970
  HTTP.post(endpoint["path"], attrs) do |response|
118
- if response.error?
971
+ if Funicular::DB.stale_generation?(generation)
972
+ block.call(nil, Funicular::DB.stale_response_error) if block
973
+ elsif response.error?
119
974
  block.call(nil, response.error_message) if block
120
975
  else
121
976
  klass = model_class || self
977
+ klass.__write_through_upsert(response.data)
122
978
  instance = klass.new(response.data)
123
979
  block.call(instance, nil) if block
124
980
  end
@@ -126,62 +982,118 @@ module Funicular
126
982
  end
127
983
 
128
984
  def self.destroy(id = nil, &block)
985
+ if local?
986
+ if block
987
+ raise ArgumentError,
988
+ "#{to_s}.destroy is synchronous on storage :local and takes no block"
989
+ end
990
+ return local.find(id).destroy
991
+ end
129
992
  endpoint = @endpoints["destroy"]
130
993
  return unless endpoint
131
994
 
132
995
  path = id ? endpoint["path"].gsub(":id", id.to_s) : endpoint["path"]
133
996
 
997
+ generation = Funicular::DB.mutation_generation
134
998
  HTTP.delete(path) do |response|
135
- if response.error?
136
- block.call(false, response.error_message) if block
999
+ if Funicular::DB.stale_generation?(generation)
1000
+ block.call(nil, Funicular::DB.stale_response_error) if block
1001
+ elsif response.error?
1002
+ block.call(nil, response.error_message) if block
137
1003
  else
138
- block.call(true, response.data) if block
1004
+ __write_through_delete(id) unless id.nil?
1005
+ block.call(true, nil) if block
139
1006
  end
140
1007
  end
141
1008
  end
142
1009
 
143
1010
  def update(attrs = nil, &block)
1011
+ if self.class.local?
1012
+ if block
1013
+ raise ArgumentError,
1014
+ "update is synchronous on storage :local and takes no block"
1015
+ end
1016
+ return __local_update(attrs)
1017
+ end
144
1018
  if attrs
145
1019
  attrs.each { |k, v| send("#{k}=", v) }
146
1020
  end
147
1021
 
148
1022
  # Validate on the client before the request (mirrors ActiveRecord#save).
149
1023
  unless valid?
150
- block.call(false, errors) if block
1024
+ block.call(nil, errors) if block
151
1025
  return
152
1026
  end
153
1027
 
154
- return if @changed_attributes.empty?
155
-
156
1028
  json_attrs = @changed_attributes.reject do |name, value|
157
1029
  schema = self.class.schema[name]
158
1030
  schema && schema["type"] == "binary"
159
1031
  end
160
1032
 
161
- return if json_attrs.empty?
1033
+ # Nothing to send (no changes, or binary-only changes that travel via
1034
+ # FileUpload): a successful no-op, reported like any success.
1035
+ if json_attrs.empty?
1036
+ block.call(self, nil) if block
1037
+ return
1038
+ end
162
1039
 
163
1040
  endpoint = self.class.endpoints["update"]
164
1041
  path = endpoint["path"].gsub(":id", @id.to_s)
165
1042
 
1043
+ generation = Funicular::DB.mutation_generation
166
1044
  HTTP.patch(path, json_attrs) do |response|
167
- if response.error?
168
- block.call(false, response.error_message) if block
1045
+ if Funicular::DB.stale_generation?(generation)
1046
+ block.call(nil, Funicular::DB.stale_response_error) if block
1047
+ elsif response.error?
1048
+ block.call(nil, response.error_message) if block
169
1049
  else
170
- # Update attributes with response data
171
- response.data.each do |key, value|
1050
+ data = response.data
1051
+ # The replica holds the server's row before the callback runs
1052
+ # (docs decision 4).
1053
+ self.class.__write_through_upsert(data)
1054
+ # Apply the server's authoritative row (defaults, callbacks and
1055
+ # normalizations included) through the codec before reporting
1056
+ # success.
1057
+ keys = data.keys
1058
+ keys_size = keys.size
1059
+ i = 0
1060
+ while i < keys_size
1061
+ key = keys[i]
1062
+ value = data[key]
1063
+ unless value.nil?
1064
+ value = Funicular::DB::Codec.decode(
1065
+ self.class.rest_attribute_type(key.to_s), value)
1066
+ end
172
1067
  instance_variable_set("@#{key}", value)
1068
+ i += 1
173
1069
  end
174
1070
  @changed_attributes = {}
175
- block.call(true, response.data) if block
1071
+ block.call(self, nil) if block
176
1072
  end
177
1073
  end
178
1074
  end
179
1075
 
180
1076
  def destroy(&block)
1077
+ if self.class.local?
1078
+ if block
1079
+ raise ArgumentError,
1080
+ "destroy is synchronous on storage :local and takes no block"
1081
+ end
1082
+ return __local_destroy
1083
+ end
181
1084
  self.class.destroy(@id, &block)
182
1085
  end
183
1086
 
184
1087
  def reload(&block)
1088
+ if self.class.local?
1089
+ if block
1090
+ raise ArgumentError,
1091
+ "reload is synchronous on storage :local and takes no block"
1092
+ end
1093
+ __sync_from_row
1094
+ @changed_attributes = {}
1095
+ return self
1096
+ end
185
1097
  self.class.find(@id) do |instance, error|
186
1098
  if instance
187
1099
  instance.instance_variables.each do |var|
@@ -192,5 +1104,248 @@ module Funicular
192
1104
  block.call(instance, error) if block
193
1105
  end
194
1106
  end
1107
+
1108
+ # ---- storage :local write internals (framework use) ------------------
1109
+ # All three writers notify local_table_changed, the hook the change-
1110
+ # event bus and snapshot scheduling attach to at boot. SQLite
1111
+ # constraint violations escape as SQLite3::Exception on purpose: they
1112
+ # are bugs, not user-facing validation (docs, "Local models").
1113
+
1114
+ # INSERT this (validated) record. Columns that were never given (nil
1115
+ # and not explicitly provided) are left out so SQL DEFAULTs apply; an
1116
+ # EXPLICIT nil binds NULL and answers to NOT NULL like any write. The
1117
+ # row is read back afterwards, so the instance reflects what the
1118
+ # table actually stores.
1119
+ def __local_insert
1120
+ klass = self.class
1121
+ columns = klass.local_columns
1122
+ codec = Funicular::DB::Codec
1123
+ now = Time.now
1124
+ if columns.has_key?("created_at") && instance_variable_get("@created_at").nil?
1125
+ instance_variable_set("@created_at", now)
1126
+ end
1127
+ if columns.has_key?("updated_at") && instance_variable_get("@updated_at").nil?
1128
+ instance_variable_set("@updated_at", now)
1129
+ end
1130
+ names = columns.keys
1131
+ # @type var cols: Array[String]
1132
+ cols = []
1133
+ # @type var marks: Array[String]
1134
+ marks = []
1135
+ # @type var binds: Array[untyped]
1136
+ binds = []
1137
+ provided = @provided_attributes
1138
+ names_size = names.size
1139
+ i = 0
1140
+ while i < names_size
1141
+ name = names[i]
1142
+ unless name == "id"
1143
+ value = instance_variable_get("@#{name}")
1144
+ if !value.nil? || (provided && provided[name])
1145
+ cols << "\"#{name}\""
1146
+ marks << "?"
1147
+ binds << codec.encode(columns[name], value)
1148
+ end
1149
+ end
1150
+ i += 1
1151
+ end
1152
+ db = klass.local_db
1153
+ # RETURNING makes the INSERT itself hand back the id. A separate
1154
+ # SELECT last_insert_rowid() would read connection-global state:
1155
+ # another Task inserting into the same database between the two
1156
+ # statements would overwrite it, and this instance would adopt the
1157
+ # other Task's id (and then sync from the other Task's row).
1158
+ sql = if cols.empty?
1159
+ "INSERT INTO \"#{klass.table_name}\" DEFAULT VALUES RETURNING \"id\""
1160
+ else
1161
+ "INSERT INTO \"#{klass.table_name}\" (#{cols.join(", ")}) " \
1162
+ "VALUES (#{marks.join(", ")}) RETURNING \"id\""
1163
+ end
1164
+ row = db.execute(sql, binds)[0]
1165
+ @id = row.is_a?(Hash) ? row.values[0] : row[0]
1166
+ __sync_from_row
1167
+ @changed_attributes = {}
1168
+ klass.local_table_changed
1169
+ self
1170
+ end
1171
+
1172
+ # Validated, synchronous update: true/false. An update with no actual
1173
+ # changes is a no-op that returns true and does not touch updated_at.
1174
+ def __local_update(attrs)
1175
+ if attrs
1176
+ keys = attrs.keys
1177
+ keys_size = keys.size
1178
+ i = 0
1179
+ while i < keys_size
1180
+ send("#{keys[i]}=", attrs[keys[i]])
1181
+ i += 1
1182
+ end
1183
+ end
1184
+ if new_record?
1185
+ raise ArgumentError,
1186
+ "cannot update a record that is not in the local table; use create"
1187
+ end
1188
+ return false unless valid?
1189
+ changed = @changed_attributes
1190
+ return true if changed.empty?
1191
+ klass = self.class
1192
+ columns = klass.local_columns
1193
+ codec = Funicular::DB::Codec
1194
+ # The updated_at stamp is a FRAMEWORK change: remember what it
1195
+ # replaced so a failed UPDATE can put it back -- otherwise a later,
1196
+ # genuinely change-free update would write (and notify) for the
1197
+ # leftover stamp alone. The USER's changes stay put on failure.
1198
+ stamped = false
1199
+ prev_stamp_ivar = nil
1200
+ prev_stamp_changed = nil
1201
+ had_stamp_changed = false
1202
+ # The rescue below must already be armed while the stamp is set and
1203
+ # the binds are encoded: Codec.encode raises ArgumentError on a
1204
+ # malformed datetime, and that failure has to revert the stamp too.
1205
+ begin
1206
+ if columns.has_key?("updated_at")
1207
+ stamped = true
1208
+ prev_stamp_ivar = instance_variable_get("@updated_at")
1209
+ had_stamp_changed = changed.has_key?("updated_at")
1210
+ prev_stamp_changed = changed["updated_at"]
1211
+ stamp = Time.now
1212
+ instance_variable_set("@updated_at", stamp)
1213
+ changed["updated_at"] = stamp
1214
+ end
1215
+ # @type var sets: Array[String]
1216
+ sets = []
1217
+ # @type var binds: Array[untyped]
1218
+ binds = []
1219
+ names = changed.keys
1220
+ names_size = names.size
1221
+ i = 0
1222
+ while i < names_size
1223
+ name = names[i]
1224
+ sets << "\"#{name}\" = ?"
1225
+ binds << codec.encode(columns[name], changed[name])
1226
+ i += 1
1227
+ end
1228
+ binds << @id
1229
+ # RETURNING answers "did the row exist?" inside the one UPDATE:
1230
+ # checking afterwards (even via the read-back below) would race a
1231
+ # concurrent delete + id-reusing re-create between the statements.
1232
+ updated = klass.local_db.execute(
1233
+ "UPDATE \"#{klass.table_name}\" SET #{sets.join(", ")} " \
1234
+ "WHERE \"id\" = ? RETURNING \"id\"", binds)
1235
+ if updated.empty?
1236
+ raise Funicular::RecordNotFound,
1237
+ "Couldn't find #{klass.to_s} with id=#{@id}"
1238
+ end
1239
+ # Re-read the row: the codec may have normalized what was written
1240
+ # (offset datetime strings fold into UTC, fractional seconds
1241
+ # truncate) and only the table knows the final values -- the
1242
+ # instance and the dirty-tracking baseline must reflect them, so a
1243
+ # re-fetch returns the same types this instance now carries.
1244
+ __sync_from_row
1245
+ rescue => e
1246
+ if stamped
1247
+ instance_variable_set("@updated_at", prev_stamp_ivar)
1248
+ if had_stamp_changed
1249
+ changed["updated_at"] = prev_stamp_changed
1250
+ else
1251
+ changed.delete("updated_at")
1252
+ end
1253
+ end
1254
+ # Explicit re-raise: a bare `raise` would not re-raise on the
1255
+ # mruby VM.
1256
+ raise e
1257
+ end
1258
+ @changed_attributes = {}
1259
+ klass.local_table_changed
1260
+ true
1261
+ end
1262
+
1263
+ def __local_destroy
1264
+ if new_record?
1265
+ raise ArgumentError,
1266
+ "cannot destroy a record that is not in the local table"
1267
+ end
1268
+ klass = self.class
1269
+ # RETURNING makes the DELETE itself report what it removed. A
1270
+ # separate SELECT changes() would read connection-global state that
1271
+ # another Task's write between the two statements overwrites --
1272
+ # dropping (or fabricating) the change notification.
1273
+ rows = klass.local_db.execute(
1274
+ "DELETE FROM \"#{klass.table_name}\" WHERE \"id\" = ? " \
1275
+ "RETURNING \"id\"", [@id])
1276
+ klass.local_table_changed unless rows.empty?
1277
+ true
1278
+ end
1279
+
1280
+ # Re-read this record's row and decode it into the instance.
1281
+ def __sync_from_row
1282
+ klass = self.class
1283
+ columns = klass.local_columns
1284
+ codec = Funicular::DB::Codec
1285
+ names = columns.keys
1286
+ # @type var quoted: Array[String]
1287
+ quoted = []
1288
+ names_size = names.size
1289
+ i = 0
1290
+ while i < names_size
1291
+ quoted << "\"#{names[i]}\""
1292
+ i += 1
1293
+ end
1294
+ row = klass.local_db.execute(
1295
+ "SELECT #{quoted.join(", ")} FROM \"#{klass.table_name}\" " \
1296
+ "WHERE \"id\" = ?", [@id])[0]
1297
+ unless row
1298
+ # The row is gone (another instance's destroy, delete_all, ...):
1299
+ # fail loud instead of keeping stale attributes. On the update
1300
+ # path this fires BEFORE changes are cleared and before
1301
+ # local_table_changed, so a 0-row UPDATE neither discards the
1302
+ # pending changes nor notifies.
1303
+ raise Funicular::RecordNotFound,
1304
+ "Couldn't find #{klass.to_s} with id=#{@id}"
1305
+ end
1306
+ # @type var baseline: Hash[String, untyped]
1307
+ baseline = {}
1308
+ i = 0
1309
+ while i < names_size
1310
+ name = names[i]
1311
+ raw = row.is_a?(Hash) ? row[name] : row[i]
1312
+ decoded = codec.decode(columns[name], raw)
1313
+ instance_variable_set("@#{name}", decoded)
1314
+ baseline[name] = decoded
1315
+ i += 1
1316
+ end
1317
+ @local_baseline = baseline
1318
+ end
1319
+
1320
+ # Hydrate this record from an already-decoded row: ivars are set
1321
+ # DIRECTLY (stored values are normalized; user writers must not run
1322
+ # again), the row becomes the dirty-tracking baseline, and nothing
1323
+ # is dirty.
1324
+ def __hydrate_local(attrs)
1325
+ keys = attrs.keys
1326
+ keys_size = keys.size
1327
+ i = 0
1328
+ while i < keys_size
1329
+ instance_variable_set("@#{keys[i]}", attrs[keys[i]])
1330
+ i += 1
1331
+ end
1332
+ __set_local_baseline(attrs)
1333
+ @changed_attributes = {}
1334
+ end
1335
+
1336
+ # Install the dirty-tracking baseline (see define_local_accessor).
1337
+ # Framework use: called with the decoded row a record was built from.
1338
+ def __set_local_baseline(attrs)
1339
+ # @type var baseline: Hash[String, untyped]
1340
+ baseline = {}
1341
+ keys = attrs.keys
1342
+ keys_size = keys.size
1343
+ i = 0
1344
+ while i < keys_size
1345
+ baseline[keys[i].to_s] = attrs[keys[i]]
1346
+ i += 1
1347
+ end
1348
+ @local_baseline = baseline
1349
+ end
195
1350
  end
196
1351
  end