tina4ruby 3.13.97 → 3.13.99

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/request.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  require "uri"
3
3
  require "json"
4
4
  require "ipaddr"
5
+ require "stringio"
5
6
 
6
7
  module Tina4
7
8
  # A Hash subclass that supports indifferent access (both string and symbol keys).
@@ -115,14 +116,87 @@ module Tina4
115
116
 
116
117
  class Request
117
118
  attr_reader :env, :method, :path, :query_string, :content_type,
118
- :path_params, :ip, :remote_ip
119
- attr_accessor :user
119
+ :ip, :remote_ip
120
+ # :route is the matched Route, attached by the dispatcher before post-match
121
+ # middleware runs (DispatchPipeline#prepare_route_request). CsrfMiddleware
122
+ # reads route.auth_required to honour a public write route (.no_auth).
123
+ attr_accessor :user, :route
120
124
 
121
125
  # Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
122
126
  TINA4_MAX_UPLOAD_SIZE = Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
123
127
 
124
128
  class PayloadTooLarge < StandardError; end
125
129
 
130
+ # Effective upload cap in bytes. Read at CALL TIME (not frozen into the
131
+ # constant at load) so the limit honours a TINA4_MAX_UPLOAD_SIZE set after
132
+ # this file was required, and so a test can lower it. Falls back to the
133
+ # constant default when the env var is unset/blank.
134
+ def self.max_upload_size
135
+ value = ENV["TINA4_MAX_UPLOAD_SIZE"]
136
+ value.nil? || value.empty? ? TINA4_MAX_UPLOAD_SIZE : value.to_i
137
+ end
138
+
139
+ # Read an IO in bounded chunks, raising PayloadTooLarge the MOMENT the
140
+ # running total exceeds the upload cap, so an over-limit body is refused as
141
+ # it arrives instead of after the whole thing is buffered. Rewinds the input
142
+ # before and after so a later reader (Rack's parser, form-token extraction)
143
+ # sees the same stream. Parity with the Python/Node per-chunk body readers.
144
+ def self.read_stream_capped(input, limit = nil)
145
+ return "" unless input
146
+
147
+ limit = max_upload_size if limit.nil?
148
+ input.rewind if input.respond_to?(:rewind)
149
+ buffer = +""
150
+ if limit&.positive?
151
+ while (chunk = input.read(65_536))
152
+ buffer << chunk
153
+ if buffer.bytesize > limit
154
+ raise PayloadTooLarge,
155
+ "Request body (#{buffer.bytesize}+ bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{limit} bytes)"
156
+ end
157
+ end
158
+ else
159
+ buffer = input.read || ""
160
+ end
161
+ input.rewind if input.respond_to?(:rewind)
162
+ buffer
163
+ end
164
+
165
+ # Persist an uploaded file's content inside target_dir under a SAFE name.
166
+ #
167
+ # The client-supplied filename is untrusted. Directory components are
168
+ # stripped (so "../../evil" or "/etc/passwd" becomes "evil"/"passwd"), a NUL
169
+ # byte or an unusable name ("", ".", "..") is refused, and the resolved path
170
+ # is confined to target_dir (realpath containment) so an upload can never
171
+ # write outside it. Returns the absolute path written; raises ArgumentError
172
+ # on an unsafe name.
173
+ def self.save_upload(file, target_dir, filename: nil)
174
+ raw = (filename || file["filename"] || file[:filename] || "").to_s
175
+ raise ArgumentError, "upload filename contains a null byte" if raw.include?("\u0000")
176
+
177
+ # Reduce to a single path segment, handling BOTH separators so a Windows
178
+ # "..\\..\\evil" cannot smuggle a directory part past a POSIX basename.
179
+ base = raw.tr("\\", "/").split("/").last.to_s
180
+ if base.empty? || base == "." || base == ".."
181
+ raise ArgumentError, "upload filename is not a usable name: #{raw.inspect}"
182
+ end
183
+
184
+ require "fileutils"
185
+ FileUtils.mkdir_p(target_dir)
186
+ dest = File.join(target_dir, base)
187
+ # Defence in depth: the resolved parent of the destination must be exactly
188
+ # the resolved target dir (guards a pre-existing symlink at target/base).
189
+ real_dir = File.realpath(target_dir)
190
+ real_parent = File.realpath(File.dirname(dest))
191
+ unless real_parent == real_dir
192
+ raise ArgumentError, "refusing to write outside #{target_dir.inspect}: #{raw.inspect}"
193
+ end
194
+
195
+ content = file["content"] || file[:content] || ""
196
+ File.binwrite(dest, content)
197
+ dest
198
+ end
199
+
126
200
  def initialize(env, path_params = {})
127
201
  @env = env
128
202
  @method = env["REQUEST_METHOD"]
@@ -131,11 +205,14 @@ module Tina4
131
205
  @content_type = env["CONTENT_TYPE"] || ""
132
206
  @path_params = path_params
133
207
 
134
- # Check upload size limit
208
+ # Check upload size limit (DECLARED Content-Length). The running per-chunk
209
+ # counter in read_stream_capped catches the chunked / under-declared case
210
+ # this check cannot see.
135
211
  content_length = (env["CONTENT_LENGTH"] || 0).to_i
136
- if content_length > TINA4_MAX_UPLOAD_SIZE
212
+ upload_limit = Tina4::Request.max_upload_size
213
+ if content_length > upload_limit
137
214
  raise PayloadTooLarge,
138
- "Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{TINA4_MAX_UPLOAD_SIZE} bytes)"
215
+ "Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{upload_limit} bytes)"
139
216
  end
140
217
 
141
218
  # Raw socket peer — NEVER honours X-Forwarded-For, so it can be trusted
@@ -154,6 +231,11 @@ module Tina4
154
231
  @json_body = nil
155
232
  @query_hash = nil
156
233
  @body_parsed = nil
234
+ # #body's own memoised RESULT can legitimately be nil (the no-body
235
+ # sentinel, REQ-BODY-DIVERGE 3.13.99), so "nil = not yet computed" (the
236
+ # convention above) cannot also mean "computed, and nil" for this one
237
+ # field — a dedicated flag distinguishes them.
238
+ @body_parsed_computed = false
157
239
  end
158
240
 
159
241
  # Is this request HTTPS from the CLIENT's point of view?
@@ -226,8 +308,15 @@ module Tina4
226
308
  # fields Hash, else the current fallback). This matches Python's
227
309
  # `request.body`, PHP's, and Node's: `body` is the PARSED payload, not
228
310
  # the raw bytes. For the raw string use `body_raw`.
311
+ #
312
+ # No-body is now a real `nil` result (REQ-BODY-DIVERGE, 3.13.99), so this
313
+ # can no longer memoise with `||=` (nil/false never "stick" — every call
314
+ # would re-run parse_body). @body_parsed_computed distinguishes "never
315
+ # computed" from "computed and the answer was nil".
229
316
  def body
230
- @body_parsed ||= parse_body
317
+ return @body_parsed if @body_parsed_computed
318
+ @body_parsed_computed = true
319
+ @body_parsed = parse_body
231
320
  end
232
321
 
233
322
  # Raw body string — the bytes exactly as the client sent them.
@@ -250,40 +339,58 @@ module Tina4
250
339
  @files ||= extract_files
251
340
  end
252
341
 
253
- # Merged params: query + body + path_params (path_params highest priority)
254
- # Supports both string and symbol key access (indifferent access).
255
- # Attach the matched route's path params AFTER construction.
342
+ # Route params ONLY — never query or body (REQ-PARAM-POLLUTION, 3.13.99,
343
+ # a param-pollution/security fix). A route `/{id}` hit with `?id=other`
344
+ # yields `params["id"]` == the route value; the client value is only ever
345
+ # in `query`. Supports both string and symbol key access (indifferent
346
+ # access — matches Route#match_path, which captures path-param names as
347
+ # SYMBOLS, so `params[:id]` and `params["id"]` both resolve). Renamed
348
+ # from `path_params` to unify the route-param accessor NAME with
349
+ # Python/PHP/Node (REQ-ROUTE-PARAM-NAME); the old MERGED `params`
350
+ # (query + body + path_params, via #build_params) is deleted outright —
351
+ # no back-compat alias (nothing in the ledger asked for one).
256
352
  #
257
- # The request is built BEFORE route matching now, so pre-match middleware
258
- # has something to read and mutate. Path params are only known once a route
353
+ # The request is built BEFORE route matching, so pre-match middleware has
354
+ # something to read and mutate. Path params are only known once a route
259
355
  # has matched, so they are set here and the memoised #params is dropped -
260
356
  # without that reset a pre-match middleware that touched #params would
261
357
  # freeze a param-less copy for the handler.
262
- def path_params=(value)
358
+ def params=(value)
263
359
  @path_params = value || {}
264
360
  @params = nil
265
361
  end
266
362
 
267
- attr_reader :path_params
268
-
269
363
  def params
270
- @params ||= build_params
364
+ @params ||= begin
365
+ result = IndifferentHash.new
366
+ @path_params.each { |k, v| result[k] = v }
367
+ result
368
+ end
271
369
  end
272
370
 
273
- # Look up a param by symbol or string key (indifferent access shortcut).
371
+ # Look up a value by key: the matched ROUTE param first, then the query
372
+ # string. A read convenience only — `params` and `query` stay separate
373
+ # collections (REQ-PARAM-POLLUTION); a route value always wins over a
374
+ # client-supplied query value of the same name. Mirrors PHP's/Node's
375
+ # `param()`. Accepts a symbol or string key (indifferent, like `params`).
274
376
  def param(key, default = nil)
275
- params[key.to_s] || params[key.to_sym] || default
377
+ value = params[key]
378
+ return value unless value.nil?
379
+ query[key.to_s] || default
276
380
  end
277
381
 
278
382
  def [](key)
279
- params[key.to_s] || params[key.to_sym] || @path_params[key.to_sym]
383
+ param(key)
280
384
  end
281
385
 
282
386
  def header(name)
283
387
  # Headers are stored in a CaseInsensitiveHash keyed by lowercase-
284
- # dashed names ("content-type", "x-api-key"). The hash normalises
285
- # the lookup case automatically, so pass the dashed form through.
286
- headers[name.to_s.tr("_", "-")]
388
+ # dashed names ("content-type", "x-api-key"). The hash normalises the
389
+ # lookup CASE automatically; this only translates the DASH convention.
390
+ # No underscore->dash remap any more (REQ-HEADER-DASH-DIVERGE,
391
+ # 3.13.99): case-fold only, matching the PHP/Node reference — a caller
392
+ # passing "content_type" no longer matches "Content-Type".
393
+ headers[name.to_s]
287
394
  end
288
395
 
289
396
  def json_body
@@ -381,9 +488,17 @@ module Tina4
381
488
  existing = @env["rack.request.form_hash"] rescue nil
382
489
  return existing if existing
383
490
 
491
+ # Enforce the running per-chunk upload cap BEFORE Rack reads the body, so
492
+ # an over-limit body is refused as it arrives rather than after Rack has
493
+ # buffered the whole thing. Outside the begin/rescue below on purpose: the
494
+ # PayloadTooLarge must propagate to the 413 handler, not be swallowed.
495
+ Tina4::Request.read_stream_capped(@env["rack.input"])
496
+
384
497
  parsed = begin
385
498
  require "rack"
386
499
  Rack::Request.new(@env).POST
500
+ rescue Tina4::Request::PayloadTooLarge
501
+ raise
387
502
  rescue StandardError => e
388
503
  Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
389
504
  nil
@@ -392,8 +507,24 @@ module Tina4
392
507
  end
393
508
 
394
509
  def parse_body
510
+ # No-body sentinel (REQ-BODY-DIVERGE, 3.13.99): checked FIRST, before any
511
+ # content-type-specific parsing, so a request with no body is nil —
512
+ # Ruby's own "nothing" value, matching Python's None/PHP's null/Node's
513
+ # undefined (each language's native absence-value; was {} here, the odd
514
+ # one out).
515
+ return nil if body_raw.nil? || body_raw.empty?
516
+
395
517
  if @content_type.include?("application/json")
396
- json_body
518
+ # Malformed JSON -> the RAW STRING (REQ-BODY-DIVERGE, 3.13.99 — pinned
519
+ # to the Python/PHP/Node majority; was {} here via #json_body, which
520
+ # swallows the distinction between "invalid" and "absent"). Deliberately
521
+ # NOT delegating to #json_body: that method keeps its own documented
522
+ # always-a-Hash, {}-on-failure contract for direct callers.
523
+ begin
524
+ JSON.parse(body_raw)
525
+ rescue JSON::ParserError, TypeError
526
+ body_raw
527
+ end
397
528
  elsif @content_type.include?("application/x-www-form-urlencoded")
398
529
  parse_query_to_hash(body_raw)
399
530
  elsif @content_type.include?("multipart/form-data")
@@ -403,8 +534,10 @@ module Tina4
403
534
  form_hash = multipart_form_hash
404
535
  if form_hash
405
536
  form_hash.each do |key, value|
406
- # Skip file entries (handled by extract_files)
537
+ # Skip file entries (handled by extract_files) - a single descriptor
538
+ # or a list of them (repeated field name).
407
539
  next if value.is_a?(Hash) && value[:tempfile]
540
+ next if value.is_a?(Array) && value.any? { |v| v.is_a?(Hash) && v[:tempfile] }
408
541
  result[key] = value
409
542
  end
410
543
  end
@@ -414,20 +547,6 @@ module Tina4
414
547
  end
415
548
  end
416
549
 
417
- def build_params
418
- p = IndifferentHash.new
419
-
420
- # Query string params
421
- query.each { |k, v| p[k.to_s] = v }
422
-
423
- # Body params
424
- body_parsed.each { |k, v| p[k.to_s] = v }
425
-
426
- # Path params (highest priority)
427
- @path_params.each { |k, v| p[k.to_s] = v }
428
- p
429
- end
430
-
431
550
  def parse_query_to_hash(qs)
432
551
  result = {}
433
552
  return result if qs.nil? || qs.empty?
@@ -442,30 +561,119 @@ module Tina4
442
561
  result = {}
443
562
  return result unless @content_type.include?("multipart/form-data")
444
563
  begin
564
+ # LIVE path: hand-scan the raw body so a REPEATED file field name is
565
+ # preserved as a LIST (Rack's POST collapses a repeated non-bracket name
566
+ # to last-wins - the multi-file data-loss this fixes). The scan reads the
567
+ # body through the capped reader, so an over-limit upload is refused as
568
+ # it arrives. Falls through to the parsed form_hash when there is no raw
569
+ # body (e.g. a spec injecting rack.request.form_hash).
570
+ scanned = scan_multipart_files
571
+ if scanned && !scanned.empty?
572
+ scanned.each do |key, list|
573
+ files = list.map { |value| build_file_upload(value) }.compact
574
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
575
+ end
576
+ return result
577
+ end
578
+
445
579
  form_hash = multipart_form_hash
446
580
  if form_hash
447
581
  form_hash.each do |key, value|
448
- if value.is_a?(Hash) && value[:tempfile]
449
- tempfile = value[:tempfile]
450
-
451
- # Indifferent-access per-file hash so file["content"],
452
- # file[:content], file["filename"], file[:filename] all work.
453
- # `content` (raw bytes, never base64) is materialised lazily on
454
- # first access (see FileUpload) — :tempfile-only handlers never
455
- # buffer large uploads in memory.
456
- file = FileUpload.new
457
- file[:filename] = value[:filename]
458
- file[:type] = value[:type]
459
- file[:tempfile] = tempfile
460
- file[:size] = tempfile.size
461
- result[key] = file
582
+ if value.is_a?(Array)
583
+ files = value.map { |v| build_file_upload(v) }.compact
584
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
585
+ else
586
+ file = build_file_upload(value)
587
+ result[key] = file if file
462
588
  end
463
589
  end
464
590
  end
591
+ rescue Tina4::Request::PayloadTooLarge
592
+ raise
465
593
  rescue StandardError
466
594
  # Multipart parsing failed
467
595
  end
468
596
  result
469
597
  end
598
+
599
+ # Build an indifferent-access per-file hash from a raw descriptor value
600
+ # ({filename:, type:, tempfile:, [content], [size]}). file["content"],
601
+ # file[:content], file["filename"] etc. all work; `content` (raw bytes,
602
+ # never base64) is materialised lazily from the tempfile on first access
603
+ # (see FileUpload) unless the scan already supplied it.
604
+ def build_file_upload(value)
605
+ return nil unless value.is_a?(Hash) && value[:tempfile]
606
+
607
+ file = FileUpload.new
608
+ file[:filename] = value[:filename]
609
+ file[:type] = value[:type]
610
+ file[:tempfile] = value[:tempfile]
611
+ file[:size] = value[:size] || (value[:tempfile].size rescue 0)
612
+ file["content"] = value[:content] if value.key?(:content) && !value[:content].nil?
613
+ file
614
+ end
615
+
616
+ # Extract the boundary token from a multipart Content-Type header.
617
+ def multipart_boundary(content_type)
618
+ content_type.to_s.split(";").each do |part|
619
+ part = part.strip
620
+ next unless part.start_with?("boundary=")
621
+
622
+ return part[9..].to_s.delete_prefix('"').delete_suffix('"')
623
+ end
624
+ nil
625
+ end
626
+
627
+ # Hand-roll the FILE parts out of the raw multipart body, keyed by field
628
+ # name, each name mapping to a LIST of descriptors so a repeated name keeps
629
+ # every file. Reads through read_stream_capped, so the running per-chunk
630
+ # upload cap is enforced here too (raising PayloadTooLarge). Returns {} when
631
+ # there is no raw body (the injected-form_hash path is used instead). Fields
632
+ # are handled by parse_body (via Rack) - this scans files only.
633
+ def scan_multipart_files
634
+ boundary = multipart_boundary(@content_type)
635
+ return {} unless boundary
636
+
637
+ body = Tina4::Request.read_stream_capped(@env["rack.input"])
638
+ return {} if body.nil? || body.empty?
639
+
640
+ body = body.dup.force_encoding("BINARY")
641
+ files = {}
642
+ delimiter = "--#{boundary}"
643
+ body.split(delimiter).each do |segment|
644
+ next if segment.empty? || segment.start_with?("--")
645
+
646
+ segment = segment.sub(/\A\r\n/, "")
647
+ header_end = segment.index("\r\n\r\n")
648
+ next unless header_end
649
+
650
+ header_section = segment[0...header_end]
651
+ content = segment[(header_end + 4)..] || ""
652
+ content = content.sub(/\r\n\z/, "")
653
+
654
+ name = nil
655
+ filename = nil
656
+ type = "application/octet-stream"
657
+ header_section.split("\r\n").each do |line|
658
+ if line =~ /content-disposition/i
659
+ name = Regexp.last_match(1) if line =~ /name="([^"]*)"/
660
+ filename = Regexp.last_match(1) if line =~ /filename="([^"]*)"/
661
+ elsif line =~ /content-type:\s*(.+)/i
662
+ type = Regexp.last_match(1).strip
663
+ end
664
+ end
665
+ next if name.nil? || filename.nil?
666
+
667
+ bytes = content.dup.force_encoding("BINARY")
668
+ (files[name] ||= []) << {
669
+ filename: filename,
670
+ type: type,
671
+ content: bytes,
672
+ size: bytes.bytesize,
673
+ tempfile: StringIO.new(bytes)
674
+ }
675
+ end
676
+ files
677
+ end
470
678
  end
471
679
  end
data/lib/tina4/router.rb CHANGED
@@ -130,7 +130,7 @@ module Tina4
130
130
 
131
131
  # Same `false` row of the return-value table a before_* hook gets:
132
132
  # keep the response the filter set, 403 only if it set nothing.
133
- Tina4::Middleware.refuse(response)
133
+ Tina4::Middleware.refuse(request, response)
134
134
  return false
135
135
  else
136
136
  return false unless Tina4::Middleware.run_before([mw], request, response)
@@ -674,6 +674,24 @@ module Tina4
674
674
  GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
675
675
  end
676
676
 
677
+ # Join a route-group prefix with a route's own path.
678
+ #
679
+ # Feature 32 (RG-DEC-01): ports PHP's normalization grammar verbatim
680
+ # (Tina4/Router.php addRoute - the reference) so Ruby converges with
681
+ # PHP/Python/Node instead of GroupContext's old bare concatenation. One
682
+ # separator between prefix and path, a single leading slash, no
683
+ # trailing slash, and any run of slashes collapsed to one - so
684
+ # group("/api") + get("users"), get("/users"), and group("/api/") +
685
+ # get("/users") all resolve to the SAME "/api/users". Before this fix,
686
+ # "#{@prefix}#{path}" bare-concatenated, so group("/api") +
687
+ # get("users") silently mis-registered at "/apiusers" (and a doubled
688
+ # trailing slash on a prefix could leave "/api//users").
689
+ def join_group_path(prefix, path)
690
+ full = "#{prefix}/#{path.sub(%r{\A/+}, '')}"
691
+ full = "/#{full.gsub(%r{\A/+|/+\z}, '')}"
692
+ full.gsub(%r{/+}, "/")
693
+ end
694
+
677
695
  # Load route files from a directory (file-based route discovery).
678
696
  #
679
697
  # mtime-tracked & re-runnable so re-discovery on /__dev/api/reload is
@@ -782,7 +800,7 @@ module Tina4
782
800
 
783
801
  %w[get post put patch delete any].each do |m|
784
802
  define_method(m) do |path, middleware: [], swagger_meta: {}, template: nil, &handler|
785
- full_path = "#{@prefix}#{path}"
803
+ full_path = Tina4::Router.join_group_path(@prefix, path)
786
804
  combined_middleware = @middleware + middleware
787
805
  Tina4::Router.add(m, full_path, handler,
788
806
  auth_handler: @auth_handler,
data/lib/tina4/seeder.rb CHANGED
@@ -12,6 +12,20 @@ module Tina4
12
12
  # fake.name # => "Sarah Johnson"
13
13
  # fake.email # => "sarah.johnson123@example.com"
14
14
  # fake.integer(1, 100)
15
+ #
16
+ # Determinism is PER-LANGUAGE, not cross-language (SEED-DETERMINISM-PERLANG):
17
+ # +FakeData.new(seed: 42)+ reproduces the identical sequence on every run
18
+ # *within Ruby*, but the same seed on Python/PHP/Node's +FakeData+ will NOT
19
+ # produce the same values -- each language uses its own PRNG (Ruby's
20
+ # +Random+, Python's Mersenne Twister, PHP's per-instance Mt19937, Node's
21
+ # mulberry32). There is no shared cross-language PRNG, and hand-rolling one
22
+ # would add cost for no real benefit -- use a seed to make ONE language's
23
+ # run reproducible, never to compare output across languages.
24
+ #
25
+ # NOT FOR SECRETS (SEED-SECRETS-DOC): this is a non-cryptographic PRNG meant
26
+ # for realistic-looking fixtures and test data. Never use it to generate API
27
+ # keys, passwords, tokens, or anything else that must be unguessable -- use
28
+ # +SecureRandom+ (or +Tina4::Auth+ for password hashing) instead.
15
29
  class FakeData
16
30
  FIRST_NAMES = %w[
17
31
  James Mary Robert Patricia John Jennifer Michael Linda David Elizabeth
@@ -171,7 +185,7 @@ module Tina4
171
185
  end
172
186
 
173
187
  def boolean
174
- @rng.rand(2)
188
+ @rng.rand(2) == 1
175
189
  end
176
190
 
177
191
  def datetime(start_year: 2020, end_year: 2026)
@@ -439,12 +453,19 @@ module Tina4
439
453
  # @param clear [Boolean] delete existing records before seeding (P2)
440
454
  # @param seed [Integer, nil] random seed for reproducible data (P3)
441
455
  # @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
456
+ # @param idempotent [Boolean] SEED-RUBY-QUIRKS fix (default false, matching
457
+ # Python/PHP/Node — none of the other three ever skip seeding based on
458
+ # existing row count). When true, opt in to the OLD Ruby-only shortcut:
459
+ # skip the run entirely (returning a zero SeedSummary, INFO-logged) when
460
+ # the table already holds >= count rows — even rows UNRELATED to this
461
+ # seeder. That check used to run unconditionally, which silently dropped
462
+ # data a caller explicitly asked to seed; it is now opt-in only.
442
463
  # @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
443
464
  #
444
465
  # @example
445
466
  # Tina4.seed_orm(User, count: 50)
446
467
  # Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
447
- def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
468
+ def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false, idempotent: false)
448
469
  fake = FakeData.new(seed: seed)
449
470
  fields = orm_class.field_definitions
450
471
  table = orm_class.table_name
@@ -460,13 +481,18 @@ module Tina4
460
481
  return SeedSummary.new
461
482
  end
462
483
 
463
- # Idempotency short-circuit (Ruby-specific, additive to the Python master):
464
- # without an explicit clear, skip when the table already has >= count rows.
465
- unless clear
484
+ # Idempotency short-circuit — OPT-IN (idempotent: true), off by default.
485
+ # It used to run unconditionally (SEED-RUBY-QUIRKS): any table already
486
+ # holding >= count rows was skipped silently, even rows with nothing to
487
+ # do with this seeder, so a caller who explicitly asked to seed could get
488
+ # zero new rows with only an INFO log to explain it. Python/PHP/Node never
489
+ # had this behaviour, so the default now matches them; the check itself is
490
+ # unchanged and still available for a caller who deliberately wants it.
491
+ if idempotent && !clear
466
492
  begin
467
493
  result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
468
494
  if result && result[:cnt].to_i >= count
469
- Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
495
+ Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping (idempotent: true)")
470
496
  return SeedSummary.new
471
497
  end
472
498
  rescue => e
@@ -544,12 +570,23 @@ module Tina4
544
570
  # @param count [Integer] number of records to insert
545
571
  # @param overrides [Hash] static values (or callables) set on every row
546
572
  # @param clear [Boolean] delete every existing row before seeding (P2)
547
- # @param seed [Integer, nil] random seed — seeds the FakeData RNG used for any
548
- # generator that is not an explicit callable (P3 / signature parity)
549
573
  # @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
550
574
  # @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
551
- def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
552
- fake = FakeData.new(seed: seed)
575
+ #
576
+ # REMOVED: the +seed:+ keyword (SEED-TABLE-SEED-INERT, SEED-DEC-01, ratified
577
+ # 2026-08-11 — same principle as the no-op ForeignKeyField +on_delete+).
578
+ # Passing +seed:+ now raises +ArgumentError: unknown keyword: :seed+ (Ruby's
579
+ # own signature error — loud and specific, nothing extra to build). For a
580
+ # reproducible run, build your own seeded +FakeData+ and close over it in
581
+ # +columns+:
582
+ #
583
+ # fake = Tina4::FakeData.new(seed: 42)
584
+ # Tina4.seed_table("users", { name: -> { fake.first_name }, age: -> { fake.integer(min: 18, max: 65) } })
585
+ #
586
+ # +seed_orm+/+seed_models+ are unaffected — they build and seed their own
587
+ # +FakeData+ internally, so their +seed:+ argument is fully deterministic.
588
+ def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, strict: false)
589
+ fake = FakeData.new
553
590
  db = Tina4.database
554
591
 
555
592
  unless db
@@ -618,8 +655,9 @@ module Tina4
618
655
  # @param clear [Boolean] clear each table first (reverse-topo order)
619
656
  # @param seed [Integer, nil] PRNG seed (P3) — applied per model
620
657
  # @param strict [Boolean] re-raise on the first failed row
658
+ # @param idempotent [Boolean] see {seed_orm} — default false, opt-in only
621
659
  # @return [Hash] +{ "ModelName" => SeedSummary }+ for each model seeded
622
- def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
660
+ def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false, idempotent: false)
623
661
  ordered = _topo_sort_models(orm_classes)
624
662
 
625
663
  if clear
@@ -634,7 +672,7 @@ module Tina4
634
672
  end
635
673
  results[model.name] = seed_orm(
636
674
  model, count: count, overrides: model_overrides || {},
637
- clear: false, seed: seed, strict: strict
675
+ clear: false, seed: seed, strict: strict, idempotent: idempotent
638
676
  )
639
677
  end
640
678
  results
@@ -657,7 +695,7 @@ module Tina4
657
695
  # { orm_class: User, count: 20 },
658
696
  # { orm_class: Order, count: 100, overrides: { status: "pending" } }
659
697
  # ], clear: true)
660
- def self.seed_batch(tasks, clear: false, strict: false)
698
+ def self.seed_batch(tasks, clear: false, strict: false, idempotent: false)
661
699
  by_class = {}
662
700
  tasks.each { |t| by_class[t[:orm_class]] = t }
663
701
  ordered_classes = _topo_sort_models(tasks.map { |t| t[:orm_class] })
@@ -675,7 +713,8 @@ module Tina4
675
713
  overrides: task[:overrides] || {},
676
714
  clear: false,
677
715
  seed: task[:seed],
678
- strict: strict
716
+ strict: strict,
717
+ idempotent: idempotent
679
718
  )
680
719
  end
681
720
 
@@ -847,24 +886,34 @@ module Tina4
847
886
  end
848
887
 
849
888
  # P4c — when a generated/static value's Ruby type clearly mismatches the
850
- # target column's field type, LOG a warning (never hard-fail). bool-in-int
851
- # is allowed (Ruby has no bool/int subclass relation, but seeded booleans are
852
- # represented as 0/1 integers here, so only flag truly suspicious cases).
889
+ # target column's field type, LOG a warning (never hard-fail). A :boolean
890
+ # field expects a native true/false (SEED-RUBY-QUIRKS: FakeData#boolean
891
+ # returns one, not the old 0/1 Integer) checked separately since Ruby has
892
+ # no single Boolean class to key a type map by.
853
893
  def self._validate_types(fields, attrs, model_name)
854
- expected = { integer: Integer, float: Float, boolean: Integer }
894
+ expected = { integer: Integer, float: Float }
855
895
  attrs.each do |name, value|
856
896
  next if value.nil?
857
897
 
858
898
  field = fields[name]
859
899
  next if field.nil?
860
900
 
901
+ if field[:type] == :boolean
902
+ unless value == true || value == false
903
+ Tina4::Log.warning(
904
+ "Seeder: #{model_name}.#{name} expected boolean but generated " \
905
+ "#{value.class} (#{value.inspect}) — inserting anyway"
906
+ )
907
+ end
908
+ next
909
+ end
910
+
861
911
  want = expected[field[:type]]
862
912
  next if want.nil?
863
913
 
864
914
  # A Float landing in an :integer column (or vice-versa) is the suspicious
865
915
  # case; everything that is_a? the expected numeric is fine.
866
916
  next if value.is_a?(want)
867
- next if want == Integer && value.is_a?(Numeric) && field[:type] == :boolean
868
917
 
869
918
  Tina4::Log.warning(
870
919
  "Seeder: #{model_name}.#{name} expected #{want} but generated " \
@@ -73,6 +73,10 @@ module Tina4
73
73
  release_resources
74
74
 
75
75
  Tina4::Log.info("Shutdown complete")
76
+ # Graceful shutdown owns the final call to reset() (Decision 24 /
77
+ # LOG-I02): flush+close owned sinks AFTER the shutdown record above,
78
+ # exactly once.
79
+ Tina4::Log.reset
76
80
  @shutdown_complete = true
77
81
  return if drained
78
82