tina4ruby 3.13.38 → 3.13.40

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.
data/lib/tina4/router.rb CHANGED
@@ -214,15 +214,33 @@ module Tina4
214
214
  # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
215
215
  class WebSocketRoute
216
216
  attr_reader :path, :handler, :path_regex, :param_names
217
+ # PUBLIC by default (mirrors GET). Flip to true with #secure (or via
218
+ # Tina4.secure_websocket) to require a valid JWT on the upgrade. Mirrors the
219
+ # HTTP Route's auth_required so the upgrade path enforces it identically.
220
+ attr_accessor :auth_required
217
221
 
218
- def initialize(path, handler)
222
+ def initialize(path, handler, auth_required: false)
219
223
  @path = normalize_path(path).freeze
220
224
  @handler = handler
225
+ @auth_required = auth_required
221
226
  @param_names = []
222
227
  @path_regex = compile_pattern(@path)
223
228
  @param_names.freeze
224
229
  end
225
230
 
231
+ # Mark this WebSocket route as requiring bearer-token auth on the upgrade.
232
+ # Returns self for chaining: Tina4::Router.websocket("/chat") { ... }.secure
233
+ def secure
234
+ @auth_required = true
235
+ self
236
+ end
237
+
238
+ # Opt back out (the default). Returns self for chaining.
239
+ def no_auth
240
+ @auth_required = false
241
+ self
242
+ end
243
+
226
244
  # Returns params hash if matched, false otherwise
227
245
  def match?(request_path)
228
246
  match = @path_regex.match(request_path)
@@ -295,13 +313,25 @@ module Tina4
295
313
  # connection — WebSocketConnection with #send, #broadcast, #close, #params
296
314
  # event — :open, :message, or :close
297
315
  # data — String payload for :message, nil for :open/:close
298
- def websocket(path, &block)
299
- ws_route = WebSocketRoute.new(path, block)
316
+ #
317
+ # PUBLIC by default (mirrors GET). Pass secure: true (the declarative way)
318
+ # OR chain .secure on the returned route (the imperative way) to require a
319
+ # valid JWT on the upgrade — both set the same auth_required flag, exactly
320
+ # like the HTTP routes support both a decorator/docblock and .secure.
321
+ def websocket(path, secure: false, &block)
322
+ ws_route = WebSocketRoute.new(path, block, auth_required: secure)
300
323
  ws_routes << ws_route
301
- Tina4::Log.debug("WebSocket route registered: #{path}")
324
+ Tina4::Log.debug("WebSocket route registered: #{path}#{secure ? ' (secured)' : ''}")
302
325
  ws_route
303
326
  end
304
327
 
328
+ # Register a SECURED WebSocket route (auth required on the upgrade). The
329
+ # declarative sibling of Tina4::Router.websocket(...).secure — mirrors the
330
+ # secure_get/secure_post pair for HTTP routes.
331
+ def secure_websocket(path, &block)
332
+ websocket(path, secure: true, &block)
333
+ end
334
+
305
335
  # Find a matching WebSocket route for a given path.
306
336
  # Returns [ws_route, params] or nil.
307
337
  def find_ws_route(path)
data/lib/tina4/swagger.rb CHANGED
@@ -7,14 +7,28 @@ module Tina4
7
7
  def generate(routes = [])
8
8
  spec = base_spec
9
9
  route_list = routes.empty? ? Tina4::Router.routes : routes
10
+ # Accumulators shared across routes: ORM models referenced
11
+ # (-> components.schemas), tags used (-> top-level tags[]), seen
12
+ # operationIds (de-dup — OpenAPI requires them unique).
13
+ ctx = { models: {}, used_tags: [], seen_ids: [] }
10
14
  route_list.each do |route|
11
- add_route_to_spec(spec, route)
15
+ add_route_to_spec(spec, route, ctx)
12
16
  end
17
+
18
+ unless ctx[:models].empty?
19
+ spec["components"]["schemas"] = {}
20
+ ctx[:models].each do |name, klass|
21
+ spec["components"]["schemas"][name] = model_schema(klass)
22
+ end
23
+ end
24
+ spec["tags"] = ctx[:used_tags].map { |t| { "name" => t } } unless ctx[:used_tags].empty?
25
+
13
26
  spec
14
27
  end
15
28
 
16
- # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. When false, callers
17
- # can choose to skip mounting /swagger entirely in production.
29
+ # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. Wired into RackApp's
30
+ # /swagger serving (v3.13.40), so it genuinely gates whether the docs are
31
+ # served — it was dead code before.
18
32
  def enabled?
19
33
  explicit = ENV["TINA4_SWAGGER_ENABLED"]
20
34
  if explicit && !explicit.empty?
@@ -51,9 +65,7 @@ module Tina4
51
65
  {
52
66
  "openapi" => "3.0.3",
53
67
  "info" => info,
54
- "servers" => [
55
- { "url" => "/" }
56
- ],
68
+ "servers" => servers,
57
69
  "paths" => {},
58
70
  "components" => {
59
71
  "securitySchemes" => {
@@ -67,48 +79,125 @@ module Tina4
67
79
  }
68
80
  end
69
81
 
70
- def add_route_to_spec(spec, route)
71
- path = convert_path(route.path)
82
+ # servers[] — TINA4_SWAGGER_SERVERS (comma-separated) for a multi-server
83
+ # list, else SWAGGER_DEV_URL, else the relative "/" default.
84
+ def servers
85
+ raw = ENV.fetch("TINA4_SWAGGER_SERVERS", "")
86
+ urls = raw.split(",").map(&:strip).reject(&:empty?)
87
+ return urls.map { |u| { "url" => u } } unless urls.empty?
88
+
89
+ dev = ENV["SWAGGER_DEV_URL"]
90
+ return [{ "url" => dev }] if dev && !dev.empty?
91
+
92
+ [{ "url" => "/" }]
93
+ end
94
+
95
+ # Valid OpenAPI path-item methods. Anything else (e.g. "any", a WebSocket
96
+ # "ws") is not a valid key and would make the document spec-invalid.
97
+ HTTP_METHODS = %w[get post put patch delete head options trace].freeze
98
+
99
+ def add_route_to_spec(spec, route, ctx)
72
100
  method = route.method.downcase
73
- return if method == "any"
101
+ return unless HTTP_METHODS.include?(method)
102
+
103
+ path = convert_path(route.path)
104
+ meta = route.swagger_meta || {}
105
+
106
+ # ORM model -> components.schemas + $ref
107
+ ref = nil
108
+ if (model = meta[:model])
109
+ klass = model.is_a?(String) ? resolve_model(model) : model
110
+ if klass
111
+ name = klass.name.split("::").last
112
+ ctx[:models][name] ||= klass
113
+ ref = "#/components/schemas/#{name}"
114
+ end
115
+ end
116
+
117
+ tags = meta[:tags] || [extract_tag(route.path)]
118
+ tags.each { |t| ctx[:used_tags] << t unless ctx[:used_tags].include?(t) }
74
119
 
75
120
  spec["paths"][path] ||= {}
76
121
  operation = {
77
- "summary" => route.swagger_meta[:summary] || "#{method.upcase} #{route.path}",
78
- "description" => route.swagger_meta[:description] || "",
79
- "tags" => route.swagger_meta[:tags] || [extract_tag(route.path)],
122
+ "operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
123
+ "summary" => meta[:summary] || "#{method.upcase} #{route.path}",
124
+ "description" => meta[:description] || "",
125
+ "tags" => tags,
80
126
  "parameters" => build_parameters(route),
81
- "responses" => route.swagger_meta[:responses] || default_responses
127
+ "responses" => meta[:responses] || model_or_default_responses(ref, meta[:model_list])
82
128
  }
83
129
 
130
+ operation["deprecated"] = true if meta[:deprecated]
131
+
84
132
  if route.auth_handler
85
133
  operation["security"] = [{ "bearerAuth" => [] }]
86
134
  end
87
135
 
88
- if %w[post put patch].include?(method) && route.swagger_meta[:request_body]
89
- operation["requestBody"] = route.swagger_meta[:request_body]
90
- elsif %w[post put patch].include?(method)
91
- operation["requestBody"] = default_request_body
136
+ if %w[post put patch].include?(method)
137
+ operation["requestBody"] = build_request_body(method, meta, ref)
92
138
  end
93
139
 
94
140
  spec["paths"][path][method] = operation
95
141
  end
96
142
 
143
+ def build_request_body(_method, meta, ref)
144
+ return meta[:request_body] if meta[:request_body]
145
+
146
+ schema = ref ? { "$ref" => ref } : { "type" => "object" }
147
+ content = { "schema" => schema }
148
+ content["example"] = meta[:example] if meta[:example]
149
+ { "content" => { "application/json" => content } }
150
+ end
151
+
152
+ def model_or_default_responses(ref, model_list)
153
+ return default_responses unless ref
154
+
155
+ schema = model_list ? { "type" => "array", "items" => { "$ref" => ref } } : { "$ref" => ref }
156
+ {
157
+ "200" => {
158
+ "description" => "Successful response",
159
+ "content" => { "application/json" => { "schema" => schema } }
160
+ }
161
+ }
162
+ end
163
+
97
164
  def convert_path(path)
98
- # Convert {id:int} to {id}
99
- path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
165
+ # {id:int} -> {id}
166
+ p = path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
167
+ # splat *path -> {path}; bare /* -> /{wildcard} (a literal '*' segment
168
+ # or an orphaned splat param is invalid OpenAPI templating)
169
+ p = p.gsub(/\*(\w+)/, '{\1}')
170
+ p.gsub(%r{(?<=/)\*(?=/|$)}, "{wildcard}")
100
171
  end
101
172
 
102
173
  def extract_tag(path)
103
174
  parts = path.split("/").reject(&:empty?)
104
- parts.first || "default"
175
+ first = parts.first
176
+ return "default" if first.nil? || first.start_with?("{", "*")
177
+
178
+ first
179
+ end
180
+
181
+ def unique_operation_id(method, path, seen)
182
+ base = method + path.gsub(%r{[/{}*]}) { |c| c == "*" ? "wildcard" : "_" }
183
+ base = base.gsub(/_+/, "_").chomp("_")
184
+ oid = base
185
+ n = 2
186
+ while seen.include?(oid)
187
+ oid = "#{base}_#{n}"
188
+ n += 1
189
+ end
190
+ seen << oid
191
+ oid
105
192
  end
106
193
 
107
194
  def build_parameters(route)
108
195
  params = []
109
196
  route.param_names.each do |param|
197
+ name = param[:name].to_s
198
+ name = "wildcard" if name == "*"
110
199
  params << {
111
- "name" => param[:name].to_s,
200
+ "name" => name,
112
201
  "in" => "path",
113
202
  "required" => true,
114
203
  "schema" => param_schema(param[:type])
@@ -118,16 +207,71 @@ module Tina4
118
207
  end
119
208
 
120
209
  def param_schema(type)
121
- case type
210
+ case type.to_s
122
211
  when "int", "integer"
123
212
  { "type" => "integer" }
124
213
  when "float", "number"
125
214
  { "type" => "number" }
215
+ when "uuid"
216
+ { "type" => "string", "format" => "uuid" }
217
+ when "slug"
218
+ { "type" => "string", "pattern" => "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
219
+ when "alpha"
220
+ { "type" => "string", "pattern" => "^[A-Za-z]+$" }
221
+ when "alnum"
222
+ { "type" => "string", "pattern" => "^[A-Za-z0-9]+$" }
223
+ else
224
+ { "type" => "string" }
225
+ end
226
+ end
227
+
228
+ # Build a components.schemas object from an ORM model's field definitions.
229
+ def model_schema(model_class)
230
+ props = {}
231
+ required = []
232
+ defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
233
+ defs.each do |name, opts|
234
+ props[name.to_s] = field_schema(opts)
235
+ required << name.to_s if opts[:nullable] == false
236
+ end
237
+ schema = { "type" => "object", "properties" => props.empty? ? {} : props }
238
+ schema["required"] = required unless required.empty?
239
+ schema
240
+ end
241
+
242
+ def field_schema(opts)
243
+ schema = map_field_type(opts[:type])
244
+ schema["readOnly"] = true if opts[:primary_key] && opts[:auto_increment]
245
+ schema
246
+ end
247
+
248
+ def map_field_type(type)
249
+ case type.to_s
250
+ when "integer"
251
+ { "type" => "integer" }
252
+ when "float", "decimal", "numeric"
253
+ { "type" => "number" }
254
+ when "boolean"
255
+ { "type" => "boolean" }
256
+ when "datetime", "date", "timestamp"
257
+ { "type" => "string", "format" => "date-time" }
258
+ when "blob"
259
+ { "type" => "string", "format" => "byte" }
126
260
  else
127
261
  { "type" => "string" }
128
262
  end
129
263
  end
130
264
 
265
+ def resolve_model(name)
266
+ Object.const_get(name)
267
+ rescue NameError
268
+ begin
269
+ Tina4.const_get(name)
270
+ rescue NameError
271
+ nil
272
+ end
273
+ end
274
+
131
275
  def default_responses
132
276
  {
133
277
  "200" => { "description" => "Successful response" },
@@ -137,16 +281,6 @@ module Tina4
137
281
  "500" => { "description" => "Internal server error" }
138
282
  }
139
283
  end
140
-
141
- def default_request_body
142
- {
143
- "content" => {
144
- "application/json" => {
145
- "schema" => { "type" => "object" }
146
- }
147
- }
148
- }
149
- end
150
284
  end
151
285
  end
152
286
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.38"
4
+ VERSION = "3.13.40"
5
5
  end
@@ -42,6 +42,79 @@ module Tina4
42
42
  allowed.include?(origin)
43
43
  end
44
44
 
45
+ # Extract a bearer token from a WebSocket upgrade handshake.
46
+ #
47
+ # Order (mirrors Python's tina4_python.websocket.ws_token):
48
+ # 1. the Authorization: Bearer <jwt> header (server/CLI/mobile clients)
49
+ # 2. the Sec-WebSocket-Protocol subprotocol in the form "bearer, <jwt>"
50
+ # (the only way a *browser* can pass a token — new WebSocket() cannot set
51
+ # headers, but it CAN offer subprotocols)
52
+ # 3. a ?token=<jwt> query-string param
53
+ # Returns the token String, or nil.
54
+ #
55
+ # +headers+ is a Hash. Lookups are case-insensitive across both the Rack-style
56
+ # "HTTP_AUTHORIZATION"/"HTTP_SEC_WEBSOCKET_PROTOCOL" keys and plain
57
+ # "authorization"/"Authorization"/"sec-websocket-protocol" keys so the same
58
+ # helper serves the rack_app upgrade path and direct callers/tests.
59
+ def self.ws_token(headers, query_string = "", subprotocol = "")
60
+ headers ||= {}
61
+ auth = headers["HTTP_AUTHORIZATION"] || headers["authorization"] || headers["Authorization"] || ""
62
+ if auth[0, 7].to_s.downcase == "bearer "
63
+ tok = auth[7..].to_s.strip
64
+ return tok.empty? ? nil : tok
65
+ end
66
+
67
+ proto = subprotocol.to_s
68
+ proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
69
+ headers["Sec-WebSocket-Protocol"] || "" if proto.empty?
70
+ parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
71
+ if parts.length >= 2 && parts[0].downcase == "bearer"
72
+ return parts[1].empty? ? nil : parts[1]
73
+ end
74
+
75
+ qs = query_string.to_s
76
+ qs = headers["QUERY_STRING"].to_s if qs.empty?
77
+ unless qs.empty?
78
+ tok = qs.split("&").each_with_object(nil) do |pair, _acc|
79
+ k, v = pair.split("=", 2)
80
+ break v if k == "token"
81
+ end
82
+ return tok unless tok.nil? || tok.to_s.empty?
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ # Per-route WebSocket authentication, checked on the upgrade.
89
+ #
90
+ # A route is secured when it requires auth (the WebSocketRoute's #auth_required
91
+ # is truthy — set by .secure on the route or by Tina4.secure_websocket). Public
92
+ # routes (the default) always pass. A secured route needs a valid JWT via the
93
+ # Authorization header, the "bearer" subprotocol, or ?token=.
94
+ #
95
+ # Returns [payload, ok] — the verified token payload (or nil) and whether the
96
+ # upgrade may proceed. Mirrors Python's ws_authorized.
97
+ def self.ws_authorized(auth_required, headers, query_string = "", subprotocol = "")
98
+ return [nil, true] unless auth_required
99
+
100
+ token = ws_token(headers, query_string, subprotocol)
101
+ return [nil, false] unless token
102
+
103
+ payload = Tina4::Auth.valid_token(token)
104
+ [payload, !payload.nil?]
105
+ end
106
+
107
+ # Whether the client offered the "bearer" subprotocol — in which case the
108
+ # handshake response must echo "bearer" as the accepted subprotocol (browsers
109
+ # reject a 101 that doesn't echo back a subprotocol they offered).
110
+ def self.ws_bearer_subprotocol_offered?(headers)
111
+ headers ||= {}
112
+ proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
113
+ headers["Sec-WebSocket-Protocol"] || ""
114
+ parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
115
+ !parts.empty? && parts[0].downcase == "bearer"
116
+ end
117
+
45
118
  # Build a WebSocket frame (server→client, never masked).
46
119
  def self.build_frame(opcode, data, fin: true)
47
120
  first_byte = (fin ? 0x80 : 0x00) | opcode
@@ -451,7 +524,9 @@ module Tina4
451
524
  # conn.on_close { puts "bye" }
452
525
  # end
453
526
  #
454
- def self.route(path, &block)
527
+ # PUBLIC by default (mirrors GET). Pass secure: true to require a valid JWT
528
+ # on the upgrade (or chain .secure on the returned route).
529
+ def self.route(path, secure: false, &block)
455
530
  @route_handlers ||= {}
456
531
  @route_handlers[path] = block
457
532
 
@@ -467,7 +542,7 @@ module Tina4
467
542
  end
468
543
  end
469
544
 
470
- Tina4::Router.websocket(path, &adapter)
545
+ Tina4::Router.websocket(path, secure: secure, &adapter)
471
546
  end
472
547
 
473
548
  # Upgrade a raw socket to a WebSocket connection and run its frame loop.
@@ -477,7 +552,7 @@ module Tina4
477
552
  # (Rack) mode the rack_app passes a process-wide shared engine here so that
478
553
  # broadcasts, rooms and the backplane span every route's connections even
479
554
  # though each upgrade keeps its own isolated event handlers on +self+.
480
- def handle_upgrade(env, socket, manager: self)
555
+ def handle_upgrade(env, socket, manager: self, auth_required: false)
481
556
  key = env["HTTP_SEC_WEBSOCKET_KEY"]
482
557
  return unless key
483
558
 
@@ -490,11 +565,30 @@ module Tina4
490
565
  return
491
566
  end
492
567
 
568
+ # Per-route auth — checked AFTER the origin allow-list and BEFORE we accept
569
+ # the handshake. A PUBLIC route (the default, mirrors GET) always passes; a
570
+ # secured route (auth_required) needs a valid JWT via the Authorization
571
+ # header, the "bearer" subprotocol, or ?token=. Missing/invalid → reject
572
+ # the upgrade with a 401 (close code 1008 equivalent) and never accept.
573
+ payload, ok = Tina4.ws_authorized(auth_required, env, env["QUERY_STRING"].to_s)
574
+ unless ok
575
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") rescue nil
576
+ socket.close rescue nil
577
+ return
578
+ end
579
+
493
580
  accept = Tina4.compute_accept_key(key)
494
581
 
582
+ # When the client offered the "bearer" subprotocol (the browser transport,
583
+ # since new WebSocket() can't set headers), echo "bearer" back as the
584
+ # accepted subprotocol — browsers reject a 101 that doesn't echo a
585
+ # subprotocol they offered. Mirrors Python's accept-subprotocol behaviour.
586
+ subproto_header = Tina4.ws_bearer_subprotocol_offered?(env) ? "Sec-WebSocket-Protocol: bearer\r\n" : ""
587
+
495
588
  response = "HTTP/1.1 101 Switching Protocols\r\n" \
496
589
  "Upgrade: websocket\r\n" \
497
590
  "Connection: Upgrade\r\n" \
591
+ "#{subproto_header}" \
498
592
  "Sec-WebSocket-Accept: #{accept}\r\n\r\n"
499
593
 
500
594
  socket.write(response)
@@ -502,6 +596,9 @@ module Tina4
502
596
  conn_id = SecureRandom.hex(16)
503
597
  ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
504
598
  connection = WebSocketConnection.new(conn_id, socket, ws_server: manager, path: ws_path)
599
+ # Expose the verified token payload on the connection (nil on public
600
+ # routes). Mirrors Python's connection.auth = payload.
601
+ connection.auth = payload
505
602
  manager.register_connection(connection)
506
603
 
507
604
  # Start the idle reaper lazily once we actually have a connection (opt-in
@@ -548,7 +645,8 @@ module Tina4
548
645
 
549
646
  class WebSocketConnection
550
647
  attr_reader :id, :rooms, :last_activity
551
- attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
648
+ attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler,
649
+ :auth
552
650
 
553
651
  def initialize(id, socket, ws_server: nil, path: "/")
554
652
  @id = id
@@ -556,6 +654,9 @@ module Tina4
556
654
  @params = {}
557
655
  @ws_server = ws_server
558
656
  @path = path
657
+ # Verified JWT payload on a secured WS route, else nil (public route).
658
+ # Mirrors Python's connection.auth.
659
+ @auth = nil
559
660
  @rooms = Set.new
560
661
  @on_message_handler = nil
561
662
  @on_close_handler = nil
data/lib/tina4.rb CHANGED
@@ -50,6 +50,7 @@ require_relative "tina4/error_overlay"
50
50
  require_relative "tina4/test_client"
51
51
  require_relative "tina4/test"
52
52
  require_relative "tina4/docs"
53
+ require_relative "tina4/docstore"
53
54
  require_relative "tina4/mcp"
54
55
 
55
56
  module Tina4
@@ -212,6 +213,10 @@ module Tina4
212
213
  # Auto-discover routes
213
214
  auto_discover(root_dir)
214
215
 
216
+ # Apply pending DB migrations on startup (non-breaking — see method doc).
217
+ # Runs AFTER route discovery / DB bind, BEFORE serving.
218
+ auto_migrate_on_startup!(root_dir)
219
+
215
220
  Tina4::Log.info("Tina4 initialized successfully")
216
221
  end
217
222
 
@@ -383,9 +388,17 @@ module Tina4
383
388
  Tina4::Router.group(prefix, auth_handler: auth, &block)
384
389
  end
385
390
 
386
- # WebSocket route registration
387
- def websocket(path, &block)
388
- Tina4::Router.websocket(path, &block)
391
+ # WebSocket route registration. PUBLIC by default (mirrors GET). Pass
392
+ # secure: true OR chain .secure on the returned route to require a valid JWT
393
+ # on the upgrade.
394
+ def websocket(path, secure: false, &block)
395
+ Tina4::Router.websocket(path, secure: secure, &block)
396
+ end
397
+
398
+ # Register a SECURED WebSocket route — declarative sibling of
399
+ # Tina4.websocket(...).secure, mirroring secure_get/secure_post.
400
+ def secure_websocket(path, &block)
401
+ Tina4::Router.secure_websocket(path, &block)
389
402
  end
390
403
 
391
404
  # Middleware hooks
@@ -440,8 +453,73 @@ module Tina4
440
453
  Tina4::Container.get(name)
441
454
  end
442
455
 
456
+ # Apply pending DB migrations on startup — NON-BREAKING. Public so it can be
457
+ # called explicitly (and unit-tested) as `Tina4.auto_migrate_on_startup!`.
458
+ #
459
+ # When a migrations/ folder exists (with at least one .sql file) and
460
+ # TINA4_AUTO_MIGRATE is not disabled, pending migrations are applied during
461
+ # boot so the schema is current with no manual `tina4ruby migrate` step. A
462
+ # failure here is logged LOUD and the service STILL starts — a bad migration
463
+ # must never take the backend down. (The explicit `tina4ruby migrate` CLI
464
+ # stays fail-fast so CI still gets a non-zero exit.)
465
+ #
466
+ # Disable with TINA4_AUTO_MIGRATE=false (also 0/no/off) — e.g. multi-instance
467
+ # production that migrates as a separate deploy step (concurrent first-apply
468
+ # can race).
469
+ def auto_migrate_on_startup!(root_dir = Dir.pwd)
470
+ # Gate 1: a migrations folder with at least one .sql file must exist.
471
+ migrations_dir = resolve_startup_migrations_dir(root_dir)
472
+ return unless migrations_dir
473
+
474
+ # Gate 2: TINA4_AUTO_MIGRATE not disabled (default "true"; false/0/no/off off).
475
+ unless Tina4::Env.is_truthy(ENV.fetch("TINA4_AUTO_MIGRATE", "true"))
476
+ Tina4::Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations")
477
+ return
478
+ end
479
+
480
+ # Gate 3: a database must be resolvable.
481
+ db = Tina4.database
482
+ unless db
483
+ Tina4::Log.debug("Startup migrations skipped (no database configured)")
484
+ return
485
+ end
486
+
487
+ begin
488
+ migration = Tina4::Migration.new(db, migrations_dir: migrations_dir)
489
+ results = migration.run
490
+ applied = Array(results).count { |r| r[:status] == "success" }
491
+ Tina4::Log.info("Applied #{applied} pending migration(s) on startup") if applied.positive?
492
+ # A migration that records as "failed" surfaces in `run`'s results but
493
+ # does NOT raise from the runner; treat a recorded failure as loud-log too.
494
+ if Array(results).any? { |r| r[:status] == "failed" }
495
+ Tina4::Log.error(
496
+ "Startup auto-migration failed — the service is starting anyway. " \
497
+ "Run `tina4ruby migrate` to retry."
498
+ )
499
+ end
500
+ rescue => e
501
+ # NON-BREAKING: never re-raise out of the startup hook.
502
+ Tina4::Log.error(
503
+ "Startup auto-migration failed: #{e.message} — the service is starting " \
504
+ "anyway. Run `tina4ruby migrate` to retry."
505
+ )
506
+ end
507
+ end
508
+
443
509
  private
444
510
 
511
+ # Resolve the migrations directory for startup auto-migration, returning it
512
+ # only when it exists AND contains at least one .sql file. Prefers
513
+ # src/migrations, falls back to migrations/ (mirrors Migration#resolve_migrations_dir).
514
+ def resolve_startup_migrations_dir(root_dir)
515
+ %w[src/migrations migrations].each do |rel|
516
+ dir = File.join(root_dir, rel)
517
+ next unless Dir.exist?(dir)
518
+ return dir unless Dir.glob(File.join(dir, "*.sql")).empty?
519
+ end
520
+ nil
521
+ end
522
+
445
523
  # Resolve auth option for route registration
446
524
  # :default => use bearer auth (default for POST/PUT/PATCH/DELETE)
447
525
  # false => no auth (public route)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.38
4
+ version: 3.13.40
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-06-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -278,6 +278,7 @@ files:
278
278
  - lib/tina4/dev_admin.rb
279
279
  - lib/tina4/dev_mailbox.rb
280
280
  - lib/tina4/docs.rb
281
+ - lib/tina4/docstore.rb
281
282
  - lib/tina4/drivers/firebird_driver.rb
282
283
  - lib/tina4/drivers/mongodb_driver.rb
283
284
  - lib/tina4/drivers/mssql_driver.rb