tina4ruby 3.13.98 → 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 (50) 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 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db66aec06f70379f6d717725d53c4d0a8a93c98f90cd65dff744e787a3c91593
4
- data.tar.gz: 40240b850e132eec2cca262192eda3f6b3d228a2ee7c691d3b8d190a1e92bac6
3
+ metadata.gz: 11d1350ed25f20cfc7c7ba9757417c0eb33e87292eb45876d3bed30119fefcca
4
+ data.tar.gz: 8bd0d33d8ae49492bd8784fb838339951ad92439bf96a615d71ffe2e488dfd7a
5
5
  SHA512:
6
- metadata.gz: 4b693353e1ae71d945e7f4b4526668c7e59a57a8fe440be5355a57015857b66b7be83663573ed74340e97122ac6cbccfb70fb22294d6c8559e058f66f6615ca2
7
- data.tar.gz: 1d350415d02e713d96ffc3730d9aad377d011f5079caafffeae046e2ff51f3813ca640eecc91559d0fe9ae29999666f99f71aadc1528716f15d8234ce3f66d63
6
+ metadata.gz: e24bac573c4d4b58642fdc65071857c4a910dac229057f92c9eca2ba0936c1163a6e89bc79452c82f7d4601a4e64b0c10ce5c0af3e35384c16344491c6f61203
7
+ data.tar.gz: 0afe568dfd38eee2c0ce7c507b71b8c0110e11be10cce5197100244d30e56ac58e119b25a1e5757df8b26a9bb74172b4739d1e94ef029729e7b3e932e2847032
data/CHANGELOG.md CHANGED
@@ -6,6 +6,90 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ### Breaking: `request.params` is route-params-only, and `path_params` is renamed `params`
10
+
11
+ Ruby had the worst version of the param-pollution bug: `params` used to merge the query
12
+ string, the parsed body, AND the route params, so `request.params["id"]` could silently
13
+ return a client-supplied value that shadowed the real route parameter. Client input now
14
+ lives only in `request.query` and `request.body`. **The route-param accessor itself is
15
+ renamed: `path_params` becomes `params`, with no alias.** A malformed JSON body used to parse
16
+ to `{}`; it now returns the raw string it failed to parse. An empty body used to parse to
17
+ `{}`; it now returns `nil`. `header()` lookup is case-fold only now (it no longer also
18
+ converts `_` to `-`).
19
+
20
+ **Migration.** Rename every `path_params` call site to `params`. Replace any `params[...]`
21
+ read of a client-supplied value with `query[...]` or `body[...]`.
22
+
23
+ ### Breaking: security headers, CSRF, and the dev server default on
24
+
25
+ `Content-Security-Policy: default-src 'self'` and the other security headers now emit by
26
+ default (relax with `TINA4_CSP`; HSTS on HTTPS via `TINA4_HSTS`). The CSRF `403` body is
27
+ unified to `{error, code, message, status}`, where Ruby used to send
28
+ `{error: "CSRF_INVALID"}`. `TINA4_CSRF=true` now actually attaches the CSRF middleware, and a
29
+ blank `TINA4_SECRET` fails closed instead of minting a forgeable public-default token. The
30
+ dev server binds `127.0.0.1` by default (`TINA4_HOST=0.0.0.0` to expose it), refuses a
31
+ cross-origin `/__dev` mutation, and never serves `.env` through the file endpoints. Static
32
+ asset serving drops the `src/assets`/`assets` search directories and now honours
33
+ `TINA4_PUBLIC_DIR`. A reflected XSS in the `403` error page is closed
34
+ (`CGI.escapeHTML`-escaped now).
35
+
36
+ **Migration.** Move assets under the configured public directory. Set `TINA4_CSP` if you
37
+ depend on inline scripts or a third-party CDN.
38
+
39
+ ### Breaking: Mongo, MSSQL, and file-upload footguns closed
40
+
41
+ An unparseable/unsupported MongoDB WHERE now raises instead of silently matching every
42
+ document (a DELETE/UPDATE with no WHERE is rejected); `truncate()` on Mongo now actually
43
+ empties the collection. The MSSQL adapter now raises on a genuinely unbindable parameter type
44
+ instead of silently stringifying it (was preventing an injection/corruption risk). A repeated
45
+ multipart file field now yields a list instead of silently dropping every upload but the
46
+ last; an over-limit upload now answers `413` mid-stream instead of after buffering the whole
47
+ body. Frond `{% include %}`/`{% extends %}`/`{% import %}` now raise on a path that escapes
48
+ the templates directory.
49
+
50
+ **Migration.** Add an explicit WHERE to any Mongo query relying on the old match-all
51
+ fallback, or call `truncate()`. Handle `request.files[x]` as a list when multiple files can
52
+ share a field name.
53
+
54
+ ### Breaking: ORM write-path and AutoCrud parity fixes
55
+
56
+ `decimal_field` now emits a real `DECIMAL(p,s)` column instead of `REAL`, dropping precision
57
+ and scale. A foreign-key auto `related_name` is now smart-pluralized (`Category` ->
58
+ `categories`, not `categorys`). `validate()` on save now enforces length/type/format, where it
59
+ used to check only for `null` -- a model that previously saved an over-length or
60
+ wrong-format value now returns `false` from `save()` and writes nothing. `create_table()`
61
+ injects the configured `soft_delete_field` automatically. `load()`'s signature changes to
62
+ `load(filter, params, include)`, from `load(arg, params)` with no `include`, and it now
63
+ JSON-coerces json columns. AutoCrud returns `422` (was `500`) on an invalid create/update, and
64
+ never accepts `is_deleted` or a client-supplied primary key in the write body. `seed_table`'s
65
+ `seed:` keyword is dropped, `FakeData#boolean` returns a native `true`/`false` (was `0`/`1`),
66
+ and `seed_orm`'s idempotency skip is opt-in now via `idempotent:` (was unconditional -- it
67
+ silently returned `seeded: 0` when the table already held enough rows).
68
+
69
+ **Migration.** Update any accessor using the misspelled `categorys` name. Pass the RNG seed to
70
+ your own `FakeData` instance instead of `seed_table(seed: ...)`. Update any `load()` call
71
+ using the old positional shape.
72
+
73
+ ### Breaking: response, database-adapter, and dev-tooling fixes
74
+
75
+ Responses gzip-compress when eligible; a cacheable 200 gets a strong ETag, and the
76
+ static-file ETag format is unified to `W/"<size>-<mtime>"` across all four frameworks. Error
77
+ pages can emit JSON now, not only HTML: `403`/`404`/`500` all negotiate `Accept`, and `404`
78
+ carries a `request_id`. An undecorated route emits only `200` in the OpenAPI spec;
79
+ `description` is omitted when unset instead of `description:""`. A route group's prefix join
80
+ is normalized to match PHP. `tina4ruby serve` now honours `TINA4_PORT`, where it used to read
81
+ only the bare `PORT` variable. `serverInfo.version` over MCP now reports the real framework
82
+ version instead of `1.0.0`. The inline `@tests` descriptor builders are renamed
83
+ `Tina4::Testing.assert_*` -> `expect_*`. `DatabaseAdapter::CONTRACT` was fictional (it named
84
+ methods no driver implemented, and omitted `execute_many`/`fetch_one`); `get_database_type`
85
+ existed on none of the seven drivers and is added to all of them. A legacy bracket-wrapped
86
+ log-level spelling that duplicated the variable name inside the brackets is rejected now; use
87
+ the plain level name (for example `TINA4_LOG_LEVEL=ALL`).
88
+
89
+ **Migration.** Rename any `assert_*` descriptor call to `expect_*`. Reconcile `TINA4_PORT` vs
90
+ bare `PORT` if your app set both. Expect every cache to revalidate once after upgrade, since
91
+ the static-file ETag format changed.
92
+
9
93
  ### Breaking: Messenger `inbox()` / `read()` item shapes (3.13.96 parity)
10
94
 
11
95
  The IMAP read path is aligned to the settled cross-framework shape (Python is the
data/lib/tina4/ai.rb CHANGED
@@ -70,10 +70,21 @@ module Tina4
70
70
  # Fetch the bytes at `url`, following up to `limit` redirects. Returns
71
71
  # nil on any network/HTTP failure so the caller can skip gracefully.
72
72
  #
73
+ # MEASURED 2026-08-13 on the real suite (not a mock): install_skills
74
+ # makes up to 18 sequential HTTPS calls (3 skills x 2 targets x
75
+ # (1 SKILL.md + refs)) to raw.githubusercontent.com, and a single
76
+ # transient timeout/reset on any one of them silently dropped that
77
+ # whole skill - `installed` came back missing "tina4-maintainer" with
78
+ # no other symptom. A short retry on the TRANSPORT-level failure modes
79
+ # only (never on a clean HTTP response, which is a real answer, not a
80
+ # glitch) is the fix a real end user running `tina4 ai` needs too, not
81
+ # just this suite.
82
+ #
73
83
  # @param url [String]
74
84
  # @param limit [Integer] max redirects to follow
85
+ # @param retries [Integer] transient-failure retries before giving up
75
86
  # @return [String, nil]
76
- def fetch_bytes(url, limit = 5)
87
+ def fetch_bytes(url, limit = 5, retries = 2)
77
88
  return nil if limit <= 0
78
89
 
79
90
  uri = URI.parse(url)
@@ -85,15 +96,33 @@ module Tina4
85
96
  response = http.get(uri.request_uri)
86
97
  case response
87
98
  when Net::HTTPSuccess
88
- response.body
99
+ return response.body
89
100
  when Net::HTTPRedirection
90
101
  location = response["location"]
91
- location ? fetch_bytes(location, limit - 1) : nil
102
+ return location ? fetch_bytes(location, limit - 1, retries) : nil
92
103
  end
104
+
105
+ # Not a success or a redirect. A throttle/server hiccup (429 rate
106
+ # limit, 502/503/504) is worth one retry; anything else (404, a real
107
+ # 4xx) is a genuine answer - retrying would not change it.
108
+ return fetch_and_retry(url, limit, retries) if retries.positive? && TRANSIENT_HTTP_CODES.include?(response.code)
109
+
110
+ nil
111
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED, SocketError, EOFError
112
+ return nil unless retries.positive?
113
+
114
+ fetch_and_retry(url, limit, retries)
93
115
  rescue StandardError
94
116
  nil
95
117
  end
96
118
 
119
+ TRANSIENT_HTTP_CODES = %w[429 500 502 503 504].freeze
120
+
121
+ def fetch_and_retry(url, limit, retries)
122
+ sleep(0.5)
123
+ fetch_bytes(url, limit, retries - 1)
124
+ end
125
+
97
126
  # Install the Tina4 SKILL.md skills into the project AND the user's
98
127
  # global ~/.claude/skills, network-fetched from the release tag matching
99
128
  # this framework version. Returns the skills fully installed.
data/lib/tina4/api.rb CHANGED
@@ -107,7 +107,12 @@ module Tina4
107
107
  end
108
108
 
109
109
  @base_url = base_url.chomp("/")
110
+ # VERSION-DEC-03 (feature 130): every outbound request carries a
111
+ # default `Tina4/<version>` User-Agent. `headers` (the caller's own,
112
+ # from the constructor kwarg) is merged in LAST, so a caller-supplied
113
+ # "User-Agent" always wins -- this is a default, never a clobber.
110
114
  @headers = {
115
+ "User-Agent" => "Tina4/#{Tina4::VERSION}",
111
116
  "Content-Type" => "application/json",
112
117
  "Accept" => "application/json"
113
118
  }.merge(headers)
@@ -3,6 +3,14 @@ require "json"
3
3
 
4
4
  module Tina4
5
5
  module AutoCrud
6
+ # PAGE-DEC-01: the maximum per-page size the list handler will honour, no
7
+ # matter what a caller asks for via ?limit=/?per_page=. 100 is not an
8
+ # arbitrary pick - it is the SAME row cap ORM.all/db.fetch already default
9
+ # to, and the number Node's AutoCrud shares via its own DEFAULT_ROW_CAP
10
+ # constant. Without this a client could request the whole table in one
11
+ # query (?limit=1000000).
12
+ MAX_PER_PAGE = 100
13
+
6
14
  class << self
7
15
  # Track registered model classes
8
16
  def models
@@ -85,7 +93,16 @@ module Tina4
85
93
  Tina4::Router.add("GET", "#{prefix}/#{table}", proc { |req, res|
86
94
  begin
87
95
  per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
96
+ # PAGE-DEC-01: cap an oversized ?per_page=/?limit= BEFORE it is used to
97
+ # derive offset below, so the offset lines up with the size actually
98
+ # used (a client can no longer request the whole table in one query).
99
+ per_page = [per_page, MAX_PER_PAGE].min
88
100
  page = (req.query["page"] || 1).to_i
101
+ # PAGE-DEC-01: clamp page < 1 -> page 1 BEFORE deriving offset, so
102
+ # offset=(page-1)*per_page can never go negative (page=0/negative used
103
+ # to hand PostgreSQL a negative OFFSET - a driver error - and silently
104
+ # misbehave on SQLite, while the envelope reported page:0).
105
+ page = [page, 1].max
89
106
  limit = per_page
90
107
  offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
91
108
  order_by = parse_sort(req.query["sort"])
@@ -149,9 +166,17 @@ module Tina4
149
166
  # POST /api/{table} -- create record
150
167
  post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
151
168
  begin
152
- attributes = req.body_parsed
153
- record = model_class.create(attributes)
154
- if record.persisted?
169
+ # CRUD-MASS-ASSIGNMENT: allow-list before the body ever reaches
170
+ # the model (guards is_deleted + strips the PK -- see the helper).
171
+ attributes = allow_listed_attributes(model_class, req.body_parsed, is_create: true)
172
+ record = model_class.new(attributes)
173
+ # CRUD-VALIDATION-STATUS (CRUD-DEC-01): build + save directly
174
+ # (not .create, which returns the literal `false` on failure --
175
+ # calling .persisted? on that raised NoMethodError, caught by the
176
+ # generic rescue below as a stray 500). #save already returns
177
+ # self/false and #errors already carries the field messages, so
178
+ # this is the SAME safe pattern the PUT handler uses.
179
+ if record.save
155
180
  res.json({ data: record.to_h }, status: 201)
156
181
  else
157
182
  res.json({ errors: record.errors }, status: 422)
@@ -181,7 +206,9 @@ module Tina4
181
206
  next res.json({ error: "Not found" }, status: 404)
182
207
  end
183
208
 
184
- attributes = req.body_parsed
209
+ # CRUD-MASS-ASSIGNMENT: allow-list -- the row is addressed by the
210
+ # URL {id}, never by the body (see the helper).
211
+ attributes = allow_listed_attributes(model_class, req.body_parsed, is_create: false)
185
212
  attributes.each do |key, value|
186
213
  setter = "#{key}="
187
214
  record.__send__(setter, value) if record.respond_to?(setter)
@@ -266,6 +293,37 @@ module Tina4
266
293
 
267
294
  private
268
295
 
296
+ # CRUD-MASS-ASSIGNMENT: filter a write body down to writable columns
297
+ # before it ever reaches `.new`/the setter loop. Only DECLARED fields
298
+ # (model_class.field_definitions) pass through; is_deleted is never
299
+ # client-writable (soft-delete is mutated only by #delete/#restore);
300
+ # and the primary key is stripped except a genuinely natural
301
+ # (single-column, non-auto_increment) key on CREATE -- the documented
302
+ # way to choose one (build_example keeps such a key in the sample
303
+ # body). Every other case strips it: an auto-increment CREATE (the
304
+ # database assigns it -- a client-supplied id previously let a POST
305
+ # silently claim/overwrite an unrelated row), and EVERY update (the
306
+ # row is addressed by the URL {id} alone; a body PK would otherwise
307
+ # move #save's own pk_filter WHERE clause off the URL-addressed row).
308
+ def allow_listed_attributes(model_class, data, is_create:)
309
+ return {} unless data.is_a?(Hash)
310
+
311
+ defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
312
+ pk_fields = model_class.respond_to?(:primary_key_fields) ? model_class.primary_key_fields.map(&:to_s) : []
313
+ single_pk = pk_fields.length == 1 ? pk_fields.first : nil
314
+ auto_increment = single_pk && defs[single_pk.to_sym] && defs[single_pk.to_sym][:auto_increment]
315
+ strip_pk = is_create ? !(single_pk && !auto_increment) : true
316
+
317
+ data.each_with_object({}) do |(key, value), allowed|
318
+ key_s = key.to_s
319
+ next unless defs.key?(key_s.to_sym)
320
+ next if key_s == "is_deleted"
321
+ next if strip_pk && pk_fields.include?(key_s)
322
+
323
+ allowed[key] = value
324
+ end
325
+ end
326
+
269
327
  # Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
270
328
  def parse_sort(sort_str)
271
329
  return nil if sort_str.nil? || sort_str.empty?
@@ -7,37 +7,110 @@ module Tina4
7
7
  # PHP's `$app->background($callback, $interval)` — a callback that runs
8
8
  # periodically alongside the server lifecycle.
9
9
  #
10
- # Ruby has no asyncio event loop, so each task runs in its own thread.
11
- # The GIL keeps it cooperative-enough for the periodic work this is meant
12
- # for (queue draining, health checks, simulators). Errors in the callback
13
- # are caught and logged so they don't kill the thread.
10
+ # Ruby has no asyncio event loop, so each task runs in its own dedicated OS
11
+ # thread, started at registration time. Because the thread runs regardless of
12
+ # which web server (Puma/WEBrick) is in front, a Ruby background task is never
13
+ # a silent no-op under production the thread IS the runtime (contrast the
14
+ # Python ASGI / PHP-FPM silent-no-op the other frameworks had to fix). The GIL
15
+ # keeps it cooperative-enough for the periodic work this is meant for (queue
16
+ # draining, health checks, simulators). Errors in the callback are caught and
17
+ # logged so they don't kill the thread.
14
18
  module Background
19
+ # Handle for one registered background task — the ONE background surface,
20
+ # identical across the four frameworks: a handle with a boolean `stop` plus a
21
+ # count. Mirrors Python's `BackgroundTask` (`handle.stop()`), PHP's
22
+ # `Tina4\BackgroundTask` (`$handle->stop()`) and Node's `background()` handle
23
+ # (`handle.stop()`).
24
+ #
25
+ # It also answers `[:callback]`/`[:interval]`/`[:thread]`/`[:running]` (read
26
+ # AND write) so a descriptor and a handle are the same object — code that
27
+ # introspected the old Hash descriptor keeps working unchanged.
28
+ class Task
29
+ attr_accessor :callback, :interval, :thread, :running
30
+
31
+ def initialize(callback, interval)
32
+ @callback = callback
33
+ @interval = interval
34
+ @thread = nil
35
+ @running = false
36
+ end
37
+
38
+ # Stop this task and DEREGISTER it. Idempotent — a second call is a safe
39
+ # no-op that returns false.
40
+ #
41
+ # @param timeout [Float] Seconds to wait for an in-flight run before killing.
42
+ # @return [Boolean] true if this call removed the task, false if already gone.
43
+ def stop(timeout: 2.0)
44
+ Background.stop_task(self, timeout: timeout)
45
+ end
46
+
47
+ # @return [Boolean] true once stop has run (the task is no longer running).
48
+ def stopped?
49
+ !@running
50
+ end
51
+
52
+ # @return [Boolean] true while the task is registered and ticking.
53
+ def running?
54
+ @running
55
+ end
56
+
57
+ # Hash-style read, so `task[:thread]` / `task[:running]` still work.
58
+ def [](key)
59
+ case key
60
+ when :callback then @callback
61
+ when :interval then @interval
62
+ when :thread then @thread
63
+ when :running then @running
64
+ end
65
+ end
66
+
67
+ # Hash-style write, so the scheduler's `task[:thread] = ...` still works.
68
+ def []=(key, value)
69
+ case key
70
+ when :callback then @callback = value
71
+ when :interval then @interval = value
72
+ when :thread then @thread = value
73
+ when :running then @running = value
74
+ end
75
+ end
76
+ end
77
+
15
78
  class << self
16
79
  # Register a periodic callback.
17
80
  #
18
81
  # @param callback [#call, nil] Object responding to `call` with no args.
19
82
  # @param interval [Float] Seconds between invocations (default 1.0).
20
83
  # @param block [Proc] Optional block (used if callback is nil).
21
- # @return [Hash] The registered task descriptor.
84
+ # @return [Task] The registered task handle — call `.stop` to end it.
22
85
  def register(callback = nil, interval: 1.0, &block)
23
86
  cb = callback || block
24
87
  raise ArgumentError, "background requires a callback or block" if cb.nil?
25
88
  raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
26
89
 
27
- task = { callback: cb, interval: interval.to_f, thread: nil, running: false }
90
+ task = Task.new(cb, interval.to_f)
28
91
  mutex.synchronize { tasks << task }
29
92
  start_task(task)
30
93
  task
31
94
  end
32
95
 
33
- # All registered task descriptors. Tests use this for introspection.
96
+ # All registered task handles. Tests use this for introspection.
34
97
  def tasks
35
98
  @tasks ||= []
36
99
  end
37
100
 
101
+ # Number of REGISTERED background tasks (stopped ones are already gone).
102
+ # The count half of the ONE shared surface, matching Python's
103
+ # `background_task_count()`, PHP's `backgroundTaskCount()` and Node's
104
+ # `backgroundTaskCount()`.
105
+ #
106
+ # @return [Integer]
107
+ def count
108
+ mutex.synchronize { tasks.length }
109
+ end
110
+
38
111
  # Stop and join every running task. Called on graceful shutdown.
39
112
  #
40
- # Each stop_task deregisters its own descriptor, so there is no blanket
113
+ # Each stop_task deregisters its own handle, so there is no blanket
41
114
  # `tasks.clear` here: clearing would ALSO drop a task registered while
42
115
  # this loop was running — leaving its thread alive but invisible in the
43
116
  # registry, which is the worse of the two failure modes.
@@ -46,29 +119,37 @@ module Tina4
46
119
  snapshot.each { |task| stop_task(task, timeout: timeout) }
47
120
  end
48
121
 
49
- # Stop a single task and DEREGISTER it. Used by tests that register, fire,
50
- # then stop, and by any subsystem that owns a task for part of its life
122
+ # Stop a single task and DEREGISTER it. Used by `Task#stop`, by graceful
123
+ # shutdown, and by any subsystem that owns a task for part of its life
51
124
  # (e.g. Mqtt::Client#stop_keepalive).
52
125
  #
53
- # The descriptor is removed from `tasks` so the registry never reports a
54
- # stopped task as registered — leaving it in place made `tasks` grow for
55
- # the life of the process on every start/stop cycle and made introspection
56
- # lie about what is actually running.
126
+ # The handle is removed from `tasks` so the registry never reports a stopped
127
+ # task as registered — leaving it in place made `tasks` grow for the life of
128
+ # the process on every start/stop cycle and made introspection lie about
129
+ # what is actually running.
130
+ #
131
+ # Idempotent: a second call on the same handle removes nothing, finds no
132
+ # thread and returns false.
57
133
  #
58
- # Idempotent: a second call on the same descriptor removes nothing, finds
59
- # no thread and returns safely.
134
+ # @return [Boolean] true if this call removed a registered task, else false.
60
135
  def stop_task(task, timeout: 2.0)
61
- task[:running] = false
62
- # Identity, not equality: `tasks.delete(task)` uses `==`, which would
63
- # take out any OTHER descriptor that happens to hold an equal Hash
64
- # (same callback, same interval). Only this exact descriptor goes.
65
- mutex.synchronize { tasks.delete_if { |registered| registered.equal?(task) } }
136
+ task.running = false
137
+ # Identity, not equality: `tasks.delete(task)` uses `==`, which would take
138
+ # out any OTHER handle that happens to compare equal. Only this exact one
139
+ # goes, and we record whether it WAS registered so stop() is a truthful bool.
140
+ was_registered = mutex.synchronize do
141
+ present = tasks.any? { |registered| registered.equal?(task) }
142
+ tasks.delete_if { |registered| registered.equal?(task) }
143
+ present
144
+ end
66
145
 
67
- thread = task[:thread]
68
- return unless thread
146
+ thread = task.thread
147
+ if thread
148
+ thread.join(timeout) || thread.kill
149
+ task.thread = nil
150
+ end
69
151
 
70
- thread.join(timeout) || thread.kill
71
- task[:thread] = nil
152
+ was_registered
72
153
  end
73
154
 
74
155
  private
@@ -78,14 +159,14 @@ module Tina4
78
159
  end
79
160
 
80
161
  def start_task(task)
81
- task[:running] = true
82
- task[:thread] = Thread.new do
83
- while task[:running]
84
- sleep task[:interval]
85
- break unless task[:running]
162
+ task.running = true
163
+ task.thread = Thread.new do
164
+ while task.running
165
+ sleep task.interval
166
+ break unless task.running
86
167
 
87
168
  begin
88
- task[:callback].call
169
+ task.callback.call
89
170
  rescue => e
90
171
  # Never let a callback error kill the thread — next interval still fires.
91
172
  if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
data/lib/tina4/cache.rb CHANGED
@@ -140,8 +140,9 @@ module Tina4
140
140
  @mutex.synchronize { @store.size }
141
141
  end
142
142
 
143
- # Generate a stable cache key from a SQL query and params.
144
- # Mirrors SQLTranslator.query_key for direct use on QueryCache.
143
+ # Generate a stable cache key from a SQL query and params. This is the ONE
144
+ # query-key source used by the ORM cache path (the former duplicate on
145
+ # SQLTranslator was removed - SQLTRANS-DEC-02).
145
146
  #
146
147
  # @param sql [String]
147
148
  # @param params [Array, nil]