tina4ruby 3.13.157 → 3.13.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a7657b8f005b569518dea4529a63b71242fd306d2a694a846f8857018f3c194
4
- data.tar.gz: 8170d55c05ce85b8517235da0a01531951567eccb2f096c60b72640268325131
3
+ metadata.gz: 9821daa34797e73cbeb2363f1df52e61a22a17eb2b7e44412483c72a02176866
4
+ data.tar.gz: 4eb81de452060f402677cecb522bd11417c968c50940a39cfbf7f9cd42b5700a
5
5
  SHA512:
6
- metadata.gz: 0277c5bd8117ca2e0ae68623026a15fc9c89e34e12d9aad1a56e02a675c924fb27c0adf64e516159d6dff1a9f5dba4f796ff6f65b36adea2659c317808955415
7
- data.tar.gz: 11d29cbb61293443b30ed7effd36d13ec8e71c5d73aeabfd01d4cd0c05321fb683c5e59b75eaea5561719d81810ccdc84fbb74f268befa4f78302304cf2ccc29
6
+ metadata.gz: b3350a878be47194f468507e910d6975c347eb717d101aeb7d78c4cec836156ab3ee06c234ec63b1c7a84cb8b8e5fcc3d11f226d688813360f7102fe55a61abb
7
+ data.tar.gz: f065e3b6de1441f44bd6104f6b9eb2e46c5c5fb856043ec9e86abf7b6a738305130259cf6697239d54a9cd390a97ca77775466e0476c8272d863d672acafb15b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ 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
+ ## 3.13.158 - 2026-11-05
10
+
11
+ ### Added
12
+ - Raw TCP service primitive (feature 142, ADR-0102): an in-process TCP listener with
13
+ on_connect/on_data/on_close callbacks and a connection registry for server->device push.
14
+ - ORM chainable `skip()` / `offset()` / `take()` on the query builder.
15
+ - CodeQL static security scanning in CI.
16
+
17
+ ### Changed
18
+ - Kafka queue backend commits consumer-group offsets durably, per record, and consumes
19
+ externally-produced records (ADR-0105).
20
+ - Frond `date` filter accepts both strftime and Twig/PHP `date()` letters (ADR-0103); an
21
+ unclosed `{% block %}` now fails loud (ADR-0104); `form.collect()` sends every selected
22
+ option of a `<select multiple>`.
23
+
24
+ ### Security
25
+ - frond.js no longer sends the signed-in bearer token to another origin or accepts a
26
+ FreshToken from one, and no longer writes the token into a `formToken` field
27
+ (GHSA-q2gc-fpw5-3v3c).
28
+ - The dev-reload WebSocket is gated on the raw socket peer, not only the Host header (GHSA-ppcq-wpj3-px7m).
29
+ - Resolves additional advisories shipped in this release; full notes in the documentation.
30
+ - A scheme-less `TINA4_DATABASE_URL` no longer logs its password (GHSA-cxqw-5cwf-wv3r).
31
+ - The dev file-save endpoint follows a dangling symlink before the containment check (GHSA-3p39-mqxh-gjxf).
32
+
9
33
  ## 3.13.157 - 2026-11-02
10
34
 
11
35
  ### Added
@@ -530,7 +530,7 @@ module Tina4
530
530
  Tina4::Log.info("Database connected: #{@driver_name}")
531
531
  @connect_error = nil
532
532
  rescue => e
533
- Tina4::Log.error("Database connection failed: #{e.message}")
533
+ Tina4::Log.error("Database connection failed: #{scrub_connection_string(e.message)}")
534
534
  @connected = false
535
535
  # REMEMBER the cause. Logging alone is not enough: the driver's own
536
536
  # connection stays nil, so the next call used to die inside the driver as
@@ -582,9 +582,21 @@ module Tina4
582
582
  def connect_error_message
583
583
  target = safe_connection_target
584
584
  "Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
585
- "#{@connect_error.class}: #{@connect_error.message}"
585
+ "#{@connect_error.class}: #{scrub_connection_string(@connect_error.message)}"
586
586
  end
587
587
 
588
+ # The driver's message with the raw connection string taken out. libpq quotes
589
+ # a scheme-less value whole as the host it could not resolve -
590
+ # could not translate host name "postgres//u:pass@h/app" to address
591
+ # - password included.
592
+ def scrub_connection_string(text)
593
+ raw = @connection_string.to_s
594
+ return text.to_s if raw.empty?
595
+
596
+ text.to_s.gsub(raw) { Tina4::DatabaseUrl.redact(raw) }
597
+ end
598
+ private :scrub_connection_string
599
+
588
600
  # The connection string with any password stripped, so a raised error can name
589
601
  # the target without leaking a credential into a log or an HTTP 500 body.
590
602
  #
@@ -180,9 +180,13 @@ module Tina4
180
180
  def self.scrub(text)
181
181
  head, separator, rest = text.to_s.partition("://")
182
182
 
183
- # No "://" - a bare file path, or an ODBC/keyword string. There is no
184
- # userinfo to find, so only the keyword form can hide a secret here.
185
- return scrub_keywords(text.to_s) if separator.empty?
183
+ # No "://" - a bare file path, an ODBC/keyword string, or a URL that lost
184
+ # its ":" or a "/" (`postgres//u:pass@host/app`). An "@" means userinfo may
185
+ # be in front of it, so take the credential out the same way as below.
186
+ if separator.empty?
187
+ text = text.to_s
188
+ return scrub_keywords(text.include?("@") ? redact_authority(text) : text)
189
+ end
186
190
 
187
191
  # A head that is not a scheme is unknown text, and unknown text may BE the
188
192
  # secret (the measured Python case was `notaurl-with-SuperSecret123`).
@@ -1727,24 +1727,34 @@ module Tina4
1727
1727
  root = real_path_of(File.expand_path(Dir.pwd))
1728
1728
  resolved = real_path_of(File.expand_path(rel_path.to_s, root))
1729
1729
  roots = [root] + extra_roots.compact.map { |r| real_path_of(File.expand_path(r.to_s)) }
1730
- unless roots.any? { |r| resolved == r || resolved.start_with?(r.chomp("/") + "/") }
1730
+ unless resolved && roots.any? { |r| r && (resolved == r || resolved.start_with?(r.chomp("/") + "/")) }
1731
1731
  raise ArgumentError, "path escapes project directory"
1732
1732
  end
1733
1733
  resolved
1734
1734
  end
1735
1735
 
1736
1736
  # Real path of +path+: the deepest existing ancestor is realpath'd and the
1737
- # not-yet-existing tail (a new file being saved) is appended.
1738
- def real_path_of(path)
1737
+ # not-yet-existing tail (a new file being saved) is appended. A dangling
1738
+ # symlink is followed to the place a write through it would land, the way
1739
+ # McpDevTools.real_path_of does. nil when it cannot be resolved (a link
1740
+ # loop, or an unreadable link), so the caller refuses it.
1741
+ def real_path_of(path, hops = 0)
1739
1742
  existing = path
1740
1743
  tail = []
1741
1744
  until File.exist?(existing) || File.symlink?(existing) || existing == File.dirname(existing)
1742
1745
  tail.unshift(File.basename(existing))
1743
1746
  existing = File.dirname(existing)
1744
1747
  end
1745
- File.join(File.realpath(existing), *tail)
1748
+ return File.join(File.realpath(existing), *tail) if File.exist?(existing)
1749
+ return nil if hops >= 40
1750
+
1751
+ # A dangling symlink. Its target is joined as text, not normalised, so a
1752
+ # ".." after a linked component is resolved the way the OS will follow it.
1753
+ target = File.readlink(existing)
1754
+ base = target.start_with?("/") ? target : File.join(File.dirname(existing), target)
1755
+ real_path_of(File.join(base, *tail), hops + 1)
1746
1756
  rescue SystemCallError
1747
- path
1757
+ nil
1748
1758
  end
1749
1759
 
1750
1760
  # Project-relative form of a resolved path, for the secret denylist.
@@ -199,6 +199,11 @@ module Tina4
199
199
  if ctx.path.start_with?("/__dev") && !Tina4::DevAdmin.dev_host_allowed?(ctx.env)
200
200
  return [403, { "content-type" => "text/plain" }, ["Forbidden: host not allowed"]]
201
201
  end
202
+ # ...and only from a raw socket peer the /__dev HTTP gate would let in, so
203
+ # a remote caller that claims "Host: localhost" cannot open it.
204
+ if ctx.path.start_with?("/__dev") && !Tina4::DevAdmin.dev_peer_allowed?((ctx.env["REMOTE_ADDR"] || "").to_s)
205
+ return [403, { "content-type" => "text/plain" }, ["Forbidden: peer not allowed"]]
206
+ end
202
207
 
203
208
  ws_result = Tina4::Router.find_ws_route(ctx.path)
204
209
  return nil unless ws_result
data/lib/tina4/frond.rb CHANGED
@@ -28,6 +28,13 @@ module Tina4
28
28
  class SafeString < String
29
29
  end
30
30
 
31
+ # Raised when a Frond template is malformed and cannot be rendered safely
32
+ # (ADR-0104) -- an unclosed {% block %} or a cut-short {% endblock % whose
33
+ # closing %} is missing. Failing loud surfaces the real cause to a developer
34
+ # (or an AI agent) instead of a page served 200 with its body quietly dropped.
35
+ class FrondSyntaxError < StandardError
36
+ end
37
+
31
38
  class Frond
32
39
  # -- Class-level registries ------------------------------------------------
33
40
  # Persist globals, filters, and tests across hot-reloads and across module
@@ -569,6 +576,62 @@ module Tina4
569
576
  @allowed_tags.include?(tag)
570
577
  end
571
578
 
579
+ PHP_MONTHS = %w[January February March April May June July August September October November December].freeze
580
+ # cwday indexing: Monday == 1 .. Sunday == 7, so PHP_DAYS[cwday - 1].
581
+ PHP_DAYS = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
582
+
583
+ # Format a date/time with Twig/PHP date() letters (English names) — ADR-0103.
584
+ #
585
+ # Letters are mapped character by character; a backslash escapes the next
586
+ # character, and any unmapped character is emitted literally (PHP's rule).
587
+ # English month/day names are hardcoded so the output matches every other
588
+ # Tina4 framework regardless of the host locale.
589
+ def self.php_date(dt, fmt)
590
+ hour = dt.respond_to?(:hour) ? dt.hour : 0
591
+ minute = dt.respond_to?(:min) ? dt.min : 0
592
+ second = dt.respond_to?(:sec) ? dt.sec : 0
593
+ h12 = hour % 12
594
+ h12 = 12 if h12.zero?
595
+ cwday = dt.cwday # 1 == Monday .. 7 == Sunday
596
+ wday = dt.wday # 0 == Sunday .. 6 == Saturday
597
+ out = +""
598
+ i = 0
599
+ n = fmt.length
600
+ while i < n
601
+ c = fmt[i]
602
+ if c == "\\" && i + 1 < n
603
+ out << fmt[i + 1]
604
+ i += 2
605
+ next
606
+ end
607
+ out << case c
608
+ when "Y" then format("%04d", dt.year)
609
+ when "y" then format("%02d", dt.year % 100)
610
+ when "m" then format("%02d", dt.month)
611
+ when "n" then dt.month.to_s
612
+ when "d" then format("%02d", dt.day)
613
+ when "j" then dt.day.to_s
614
+ when "H" then format("%02d", hour)
615
+ when "G" then hour.to_s
616
+ when "h" then format("%02d", h12)
617
+ when "g" then h12.to_s
618
+ when "i" then format("%02d", minute)
619
+ when "s" then format("%02d", second)
620
+ when "A" then hour < 12 ? "AM" : "PM"
621
+ when "a" then hour < 12 ? "am" : "pm"
622
+ when "D" then PHP_DAYS[cwday - 1][0, 3]
623
+ when "l" then PHP_DAYS[cwday - 1]
624
+ when "M" then PHP_MONTHS[dt.month - 1][0, 3]
625
+ when "F" then PHP_MONTHS[dt.month - 1]
626
+ when "N" then cwday.to_s
627
+ when "w" then wday.to_s
628
+ else c
629
+ end
630
+ i += 1
631
+ end
632
+ out
633
+ end
634
+
572
635
  # Utility: HTML escape
573
636
  def self.escape_html(str)
574
637
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
@@ -989,7 +1052,14 @@ module Tina4
989
1052
  end
990
1053
  end
991
1054
 
992
- pos = content_start unless matched # malformed -- skip forward
1055
+ unless matched
1056
+ # Unclosed {% block %} (or a cut-short {% endblock % whose %} is
1057
+ # missing, so the close regex never matches) -- fail loud
1058
+ # (tina4#286/#290) rather than silently dropping the body. Still
1059
+ # bounded: we raise, we never loop (tina4#271).
1060
+ raise FrondSyntaxError,
1061
+ "{% block #{name} %} is never closed (missing or malformed {% endblock %})"
1062
+ end
993
1063
  end
994
1064
 
995
1065
  blocks
@@ -1061,12 +1131,10 @@ module Tina4
1061
1131
  end
1062
1132
 
1063
1133
  if close_match.nil?
1064
- # Malformed template (no matching endblock) -- keep the rest
1065
- # verbatim rather than lose it, the same leniency extract_blocks
1066
- # applies to this case.
1067
- pieces << source[m_open.begin(0)..]
1068
- pos = len
1069
- break
1134
+ # Unclosed {% block %} -- fail loud (tina4#286/#290), matching
1135
+ # extract_blocks, rather than silently keeping the rest verbatim.
1136
+ raise FrondSyntaxError,
1137
+ "{% block #{name} %} is never closed (missing or malformed {% endblock %})"
1070
1138
  end
1071
1139
 
1072
1140
  parent_content = source[content_start...close_match.begin(0)]
@@ -3195,12 +3263,14 @@ module Tina4
3195
3263
  begin
3196
3264
  if v.is_a?(String)
3197
3265
  dt = DateTime.parse(v)
3198
- dt.strftime(fmt)
3199
3266
  elsif v.respond_to?(:strftime)
3200
- v.strftime(fmt)
3267
+ dt = v
3201
3268
  else
3202
- v.to_s
3269
+ return v.to_s
3203
3270
  end
3271
+ # A strftime-style format (any with %) stays strftime — back-compatible;
3272
+ # any other format is Twig/PHP date() letters (ADR-0103).
3273
+ fmt.include?("%") ? dt.strftime(fmt) : Frond.php_date(dt, fmt)
3204
3274
  rescue
3205
3275
  v.to_s
3206
3276
  end
@@ -9,12 +9,12 @@
9
9
  {
10
10
  "path": "js/frond.min.js",
11
11
  "component": "tina4-css",
12
- "sha256": "4cbaf2131492225d87205d8982c4af5035538e9812e704b43f217f4ac4a5dafc"
12
+ "sha256": "1c46dc669fe6ebed980bef6ecab197b73fc0697488aff5746ee4fe6e8a64d7cd"
13
13
  },
14
14
  {
15
15
  "path": "js/frond.js",
16
16
  "component": "tina4-css",
17
- "sha256": "77560a0800034d91f8ccb5d726b69ff763881b96e39c5d0b6ee7186060c2d27a"
17
+ "sha256": "471cdcf1eae45142dce5a9755ce4d89e4e816989f3534ae2af56308ec4dcae65"
18
18
  },
19
19
  {
20
20
  "path": "js/tina4.min.js",
@@ -23,6 +23,13 @@ var _frondModule = (() => {
23
23
  frond: () => frond
24
24
  });
25
25
  var _token = null;
26
+ function _sameOrigin(url) {
27
+ try {
28
+ return new URL(url, window.location.href).origin === window.location.origin;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
26
33
  function request(url, options) {
27
34
  let opts;
28
35
  if (typeof options === "function") {
@@ -33,7 +40,8 @@ var _frondModule = (() => {
33
40
  const method = (opts.method || "GET").toUpperCase();
34
41
  const xhr = new XMLHttpRequest();
35
42
  xhr.open(method, url, true);
36
- if (_token !== null) {
43
+ const sameOrigin = _sameOrigin(url);
44
+ if (_token !== null && sameOrigin) {
37
45
  xhr.setRequestHeader("Authorization", "Bearer " + _token);
38
46
  }
39
47
  if (opts.headers) {
@@ -57,7 +65,8 @@ var _frondModule = (() => {
57
65
  }
58
66
  xhr.onload = function() {
59
67
  const freshToken = xhr.getResponseHeader("FreshToken");
60
- if (freshToken && freshToken !== "") {
68
+ const answeredBySameOrigin = sameOrigin && (!xhr.responseURL || _sameOrigin(xhr.responseURL));
69
+ if (freshToken && freshToken !== "" && answeredBySameOrigin) {
61
70
  _token = freshToken;
62
71
  }
63
72
  let content = xhr.response;
@@ -169,8 +178,12 @@ var _frondModule = (() => {
169
178
  * Collect all form field values into a FormData object.
170
179
  *
171
180
  * Handles inputs, selects, textareas, file uploads (including
172
- * multi-file), checkboxes, and radio buttons. Updates formToken
173
- * hidden fields automatically.
181
+ * multi-file), checkboxes, and radio buttons.
182
+ *
183
+ * A `formToken` field is sent as the server rendered it. frond never
184
+ * writes the bearer into it: the bearer travels in the Authorization
185
+ * header (own origin only), and the server accepts only a form token in
186
+ * the formToken slot.
174
187
  *
175
188
  * @param formId - DOM id of the form (without '#').
176
189
  * @returns Populated FormData instance.
@@ -180,9 +193,6 @@ var _frondModule = (() => {
180
193
  const elements = document.querySelectorAll("#" + formId + " select, #" + formId + " input, #" + formId + " textarea");
181
194
  for (let i = 0; i < elements.length; i++) {
182
195
  const el = elements[i];
183
- if (el.name === "formToken" && _token !== null) {
184
- el.value = _token;
185
- }
186
196
  if (!el.name) continue;
187
197
  if (el.type === "file") {
188
198
  const files = el.files;
@@ -204,6 +214,15 @@ var _frondModule = (() => {
204
214
  } else if (el.type !== "radio") {
205
215
  fd.append(el.name, "0");
206
216
  }
217
+ } else if (el.type === "select-multiple") {
218
+ const options = el.selectedOptions;
219
+ for (let o = 0; o < options.length; o++) {
220
+ const option = options[o];
221
+ const group = option.parentElement;
222
+ if (option.disabled) continue;
223
+ if (group instanceof HTMLOptGroupElement && group.disabled) continue;
224
+ fd.append(el.name, option.value);
225
+ }
207
226
  } else {
208
227
  fd.append(el.name, el.value === "" ? "" : el.value);
209
228
  }
@@ -1,2 +1,2 @@
1
- var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var q=Object.prototype.hasOwnProperty;var A=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of H(s))!q.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};A(z,{frond:()=>C});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},e.timeout!==0&&(n.timeout=e.timeout||3e4,n.ontimeout=function(){e.onError&&e.onError(n.status,n)}),n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e,o){let n=s||"content";v(t,{method:"GET",onSuccess:function(c,u){if(document.getElementById(n)){let r=h(c,n);e&&e(r,c)}else e&&e(c)},onError:o})}function k(t,s,e,o,n){let c=e||"content";v(t,{method:"POST",body:s,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r,u)},onError:n})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o,n){let c=x.collect(t);k(s,c,e||"message",o,n)},show:function(t,s,e,o,n){let c=t.toUpperCase();(t==="create"||t==="edit")&&(c="GET"),t==="delete"&&(c="DELETE");let u=e||"form";v(s,{method:c,onSuccess:function(r){let i="";if(r&&r.message!==void 0)i=h(r.message,u);else if(document.getElementById(u))i=h(r,u);else{o&&o(r);return}o&&o(i)},onError:n})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function m(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function p(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=m(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||p()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function m(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){p(m(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){p(m(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function X(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,m=(r-o)/2/i+c,p=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+m+",left="+l);return window.focus&&p&&p.focus(),p}function B(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function P(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function F(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(F(t,s),t.children.length||s.children.length?L(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function L(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),m=null;l&&n[l]?m=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(m=e[r]),m&&m.tagName===i.tagName?(G(m,i),reused.push(m),c.push(m)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function R(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}L(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){R(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&R(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var C={request:v,load:_,post:k,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:X,report:B,graphql:P,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=C,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
1
+ var _frondModule=(()=>{var E=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var q=Object.getOwnPropertyNames;var A=Object.prototype.hasOwnProperty;var D=(t,r)=>{for(var e in r)E(t,e,{get:r[e],enumerable:!0})},_=(t,r,e,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of q(r))!A.call(t,n)&&n!==e&&E(t,n,{get:()=>r[n],enumerable:!(o=H(r,n))||o.enumerable});return t};var W=t=>_(E({},"__esModule",{value:!0}),t);var K={};D(K,{frond:()=>C});var v=null;function R(t){try{return new URL(t,window.location.href).origin===window.location.origin}catch{return!1}}function h(t,r){let e;typeof r=="function"?e={onSuccess:r}:e=r||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;n.open(o,t,!0);let c=R(t);if(v!==null&&c&&n.setRequestHeader("Authorization","Bearer "+v),e.headers)for(let s in e.headers)Object.prototype.hasOwnProperty.call(e.headers,s)&&n.setRequestHeader(s,e.headers[s]);let u=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?u=e.body:typeof e.body=="object"?(u=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(u=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let s=n.getResponseHeader("FreshToken"),i=c&&(!n.responseURL||R(n.responseURL));s&&s!==""&&i&&(v=s);let l=n.response;try{l=JSON.parse(l)}catch{}if(n.responseURL){let f=new URL(t,window.location.href).href;if(n.responseURL!==f){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(l,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},e.timeout!==0&&(n.timeout=e.timeout||3e4,n.ontimeout=function(){e.onError&&e.onError(n.status,n)}),n.send(u)}function y(t,r){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(s){s.remove()}),r!==null){let s=document.getElementById(r);return s&&(c.children.length>0?s.replaceChildren.apply(s,Array.from(c.children)):s.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,s.appendChild(l)})),""}return u.forEach(function(s){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=s.textContent,document.body.appendChild(i)}),c.innerHTML}function U(t,r,e,o){let n=r||"content";h(t,{method:"GET",onSuccess:function(c,u){if(document.getElementById(n)){let s=y(c,n);e&&e(s,c)}else e&&e(c)},onError:o})}function L(t,r,e,o,n){let c=e||"content";h(t,{method:"POST",body:r,onSuccess:function(u){let s="";if(u&&u.message!==void 0)s=y(u.message,c);else if(document.getElementById(c))s=y(u,c);else{o&&o(u);return}o&&o(s,u)},onError:n})}var M={collect:function(t){let r=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let s=c[u];if(s!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),r.append(i,s,s.name)}}}else if(n.type==="checkbox"||n.type==="radio")n.checked?r.append(n.name,n.value):n.type!=="radio"&&r.append(n.name,"0");else if(n.type==="select-multiple"){let c=n.selectedOptions;for(let u=0;u<c.length;u++){let s=c[u],i=s.parentElement;s.disabled||i instanceof HTMLOptGroupElement&&i.disabled||r.append(n.name,s.value)}}else r.append(n.name,n.value===""?"":n.value)}return r},submit:function(t,r,e,o,n){let c=M.collect(t);L(r,c,e||"message",o,n)},show:function(t,r,e,o,n){let c=t.toUpperCase();(t==="create"||t==="edit")&&(c="GET"),t==="delete"&&(c="DELETE");let u=e||"form";h(r,{method:c,onSuccess:function(s){let i="";if(s&&s.message!==void 0)i=y(s.message,u);else if(document.getElementById(u))i=y(s,u);else{o&&o(s);return}o&&o(i)},onError:n})}};function x(t,r){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...r||{}},o=null,n=!1,c=e.reconnectDelay,u=0,s=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(d){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof d=="string"?d:JSON.stringify(d))},on:function(d,a){return i[d]||(i[d]=[]),i[d].push(a),function(){let p=i[d],g=p.indexOf(a);g>=0&&p.splice(g,1)}},close:function(d,a){n=!0,s&&(clearTimeout(s),s=null),o&&o.close(d||1e3,a||""),l.status="closed"}};function f(d){if(typeof d!="string")return d;try{return JSON.parse(d)}catch{return d}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",s=setTimeout(function(){s=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let d of i.open)d()},o.onmessage=function(d){let a=f(d.data);for(let p of i.message)p(a)},o.onclose=function(d){l.status="closed",e.onClose(d.code,d.reason);for(let a of i.close)a(d.code,d.reason);n||m()},o.onerror=function(d){e.onError(d);for(let a of i.error)a(d)}}return w(),l}function I(t,r){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...r||{}},o=null,n=!1,c=e.reconnectDelay,u=0,s=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,p){return i[a]||(i[a]=[]),i[a].push(p),function(){let g=i[a],T=g.indexOf(p);T>=0&&g.splice(T,1)}},close:function(){n=!0,s&&(clearTimeout(s),s=null),o&&(o.close(),o=null),l.status="closed"}};function f(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,p){for(let g of i.message)g(a,p||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",s=setTimeout(function(){s=null,d()},c),c=Math.min(c*2,e.maxReconnectDelay))}function d(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(f(a.data),null)};for(let a of e.events)o.addEventListener(a,function(p){m(f(p.data),a)});o.onerror=function(a){e.onError(a);for(let p of i.error)p(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let p of i.close)p(null);n||w()}}}return d(),l}var j={set:function(t,r,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(r||"")+o+"; path=/"},get:function(t){let r=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(r)===0)return n.substring(r.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function X(t,r){let e=document.getElementById("message");if(!e)return;let o=r||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function B(t,r,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,s=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,f=(s-o)/2/i+c,m=window.open(t,r,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+f+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,r,e,o){h(t,{method:"POST",body:{query:r,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let r=t.dataset;return r&&r.key?r.key:null}function G(t,r){let e=r.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){r.hasAttribute(n.name)||t.removeAttribute(n.name)})}function J(t,r){let e=r.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(G(t,r),t.children.length||r.children.length?O(t,r):t.innerHTML!==r.innerHTML&&(t.innerHTML=r.innerHTML))}function O(t,r){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(r.children),n={};e.forEach(function(s){let i=b(s);i&&(n[i]=s)});let c=[];for(let s=0;s<o.length;s++){let i=o[s],l=b(i),f=null;l&&n[l]?f=n[l]:!l&&e[s]&&!b(e[s])&&e[s].tagName===i.tagName&&(f=e[s]),f&&f.tagName===i.tagName?(J(f,i),reused.push(f),c.push(f)):c.push(i)}let u=t.firstElementChild;for(let s=0;s<c.length;s++){let i=c[s];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(s){c.indexOf(s)===-1&&s.parentNode===t&&t.removeChild(s)}),reused}function k(t,r){let e=document.createElement("div");if(e.innerHTML=r,!e.children.length||!t.children.length){t.innerHTML=r;return}O(t,e)}function N(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function z(t,r){return t&&typeof t=="object"?t.type==="live"?r&&t.name&&t.name!==r?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),s=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||h(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},s);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=x(N(o.getAttribute("data-ws")));u.on("message",function(s){let i=z(s,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var C={request:h,load:U,post:L,inject:y,form:M,ws:x,sse:I,live:S,cookie:j,message:X,popup:B,report:P,graphql:F,get token(){return v},set token(t){v=t}};typeof window<"u"&&(window.frond=C,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(K);})();
2
2
  /* Frond v2.2.0 - tina4.com */
@@ -186,6 +186,38 @@ module Tina4
186
186
  self
187
187
  end
188
188
 
189
+ # Set OFFSET (rows to skip) as its own chainable verb.
190
+ #
191
+ # `limit(n, offset)` buries the offset as a second positional arg; this
192
+ # exposes it on its own, so `.offset(20).limit(10)` reads naturally and
193
+ # matches the SQL keyword.
194
+ #
195
+ # @param count [Integer] Number of rows to skip.
196
+ # @return [self]
197
+ def offset(count)
198
+ @offset_val = count
199
+ self
200
+ end
201
+
202
+ # Alias for {#offset} — the Mongo/Eloquent name for the same thing.
203
+ #
204
+ # Mirrors the DocStore cursor's `skip` and the `:skip` key that {#to_mongo}
205
+ # already emits, so a query reads the same whether it targets SQL or MongoDB.
206
+ #
207
+ # @param count [Integer] Number of rows to skip.
208
+ # @return [self]
209
+ def skip(count)
210
+ offset(count)
211
+ end
212
+
213
+ # Alias for {#limit} — the Eloquent name; pairs with {#skip}.
214
+ #
215
+ # @param count [Integer] Maximum rows to return.
216
+ # @return [self]
217
+ def take(count)
218
+ limit(count)
219
+ end
220
+
189
221
  # Build and return the SQL string without executing.
190
222
  #
191
223
  # @return [String] The constructed SQL query.
@@ -42,6 +42,11 @@ module Tina4
42
42
  }.merge(security)
43
43
  @consumer = Rdkafka::Config.new(consumer_config).consumer
44
44
  @subscribed_topics = []
45
+ # Records polled but not yet acknowledged, keyed by the id the Queue
46
+ # hands back to acknowledge(), valued by the rdkafka message (its
47
+ # topic/partition/offset). acknowledge() commits exactly THAT record's
48
+ # offset, never a single "last message" slot (ADR-0022 decision 3).
49
+ @in_flight = {}
45
50
  rescue LoadError
46
51
  raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
47
52
  end
@@ -157,12 +162,28 @@ module Tina4
157
162
  return nil unless msg
158
163
 
159
164
  data = Tina4.parse_json(msg.payload)
160
- @last_message = msg
165
+ coordinate = "#{topic}:#{msg.partition}:#{msg.offset}"
166
+
167
+ # A record carrying a "payload" key is a Tina4 envelope: keep its own id
168
+ # (falling back to the coordinate) and carry attempts/error back so a
169
+ # redelivery does not reset the count and re-produce a poison record
170
+ # forever. A record WITHOUT one is externally produced (Debezium/CDC and
171
+ # friends, no envelope, no id -- tina4#274): its whole value IS the
172
+ # payload, and the coordinate becomes its id. Either way the job gets the
173
+ # id acknowledge() maps back to this record's offset.
174
+ job =
175
+ if data.is_a?(Hash) && data.key?("payload")
176
+ id = data["id"].to_s.empty? ? coordinate : data["id"]
177
+ Tina4::Job.new(
178
+ topic: topic, payload: data["payload"], id: id,
179
+ attempts: data["attempts"] || 0, error: data["error"]
180
+ )
181
+ else
182
+ Tina4::Job.new(topic: topic, payload: data, id: coordinate, attempts: 0)
183
+ end
161
184
 
162
- # attempts and error MUST be carried back (Job.from_payload does this);
163
- # rebuilding from topic/payload/id alone reset attempts to 0 on every
164
- # redelivery, so a poison record would be re-produced forever.
165
- Tina4::Job.from_payload(data)
185
+ @in_flight[job.id.to_s] = msg
186
+ job
166
187
  rescue Rdkafka::RdkafkaError
167
188
  nil
168
189
  end
@@ -177,8 +198,19 @@ module Tina4
177
198
  0
178
199
  end
179
200
 
180
- def acknowledge(_message)
181
- @consumer.commit if @last_message
201
+ # Commit the offset JUST PAST the acknowledged record so the group never
202
+ # re-reads it, even across a restart (ADR-0022 decisions 2 + 4). The offset
203
+ # is committed for the specific record dequeue() tracked against this job's
204
+ # id -- NOT a bare @consumer.commit of the whole polled position, which
205
+ # would commit past records still in flight when jobs are completed out of
206
+ # order (decision 3). A job never dequeued (or already acked) is a no-op.
207
+ def acknowledge(message)
208
+ msg = @in_flight.delete(message.id.to_s)
209
+ return unless msg
210
+
211
+ tpl = Rdkafka::Consumer::TopicPartitionList.new
212
+ tpl.add_topic_and_partitions_with_offsets(msg.topic, msg.partition => msg.offset + 1)
213
+ @consumer.commit(tpl, false)
182
214
  end
183
215
 
184
216
  def requeue(message)
@@ -106,10 +106,21 @@ module Tina4
106
106
  # otherwise leak on every single shutdown.
107
107
  def release_resources
108
108
  close_websockets
109
+ stop_tcp_services
109
110
  stop_background_tasks
110
111
  close_database
111
112
  end
112
113
 
114
+ # Drain live raw TCP service connections (feature 142) within the deadline,
115
+ # firing their on_close handlers, alongside the HTTP/WebSocket drain.
116
+ def stop_tcp_services
117
+ return unless defined?(Tina4::Tcp)
118
+
119
+ Tina4::Tcp.drain_all(timeout: @timeout.to_f)
120
+ rescue StandardError => e
121
+ Tina4::Log.error("Error draining TCP services: #{e.message}")
122
+ end
123
+
113
124
  # Block until initiate_shutdown has finished every teardown step.
114
125
  #
115
126
  # stop_accepting unblocks the built-in server's accept loop immediately, so
@@ -158,6 +169,8 @@ module Tina4
158
169
  return unless @server.respond_to?(:shutdown)
159
170
 
160
171
  @server.shutdown
172
+ # Raw TCP listeners stop accepting at the same moment as HTTP.
173
+ Tina4::Tcp.stop_accepting_all if defined?(Tina4::Tcp)
161
174
  Tina4::Log.info("Stopped accepting new connections")
162
175
  rescue StandardError => e
163
176
  Tina4::Log.error("Error closing the listening socket: #{e.message}")
data/lib/tina4/tcp.rb ADDED
@@ -0,0 +1,573 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 Code Infinity
4
+ # SPDX-License-Identifier: MPL-2.0
5
+ # This Source Code Form is subject to the terms of the Mozilla Public
6
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
7
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
+
9
+ require "socket"
10
+
11
+ module Tina4
12
+ # Raw TCP service — a user-facing, non-HTTP, long-lived binary TCP listener.
13
+ #
14
+ # Governed by ADR-0102 (feature 142), and the Python reference
15
+ # tina4_python/tcp. A Tina4 app exposes its own protocol — a GPS/GPRS
16
+ # car-tracker gateway (GT06/TK103/Concox/Teltonika) is the driving case — in
17
+ # the SAME process as its HTTP dashboard, reusing config, logging, ORM and
18
+ # queue.
19
+ #
20
+ # svc = Tina4::Tcp.service(5023, idle_timeout: 0, max_connections: 5000)
21
+ # svc.on_data do |conn, data|
22
+ # frame = parse(data) # the APP frames the stream
23
+ # conn.identify(frame.imei) if frame.login?
24
+ # conn.send(ack_for(frame)) # ACK on the same socket
25
+ # end
26
+ #
27
+ # # server -> device push from an HTTP route:
28
+ # Tina4::Tcp.get.send_to(imei, build_command("set_interval", 30))
29
+ #
30
+ # Ruby's thread-per-connection model satisfies D1 (in-process): the acceptor
31
+ # thread runs in the SAME VM as the HTTP server, so the device gateway and its
32
+ # dashboard share one process. The framework owns the socket hard-parts:
33
+ # lifecycle, max_connections refusal, an idle reaper (default OFF for
34
+ # long-lived device sockets), graceful-shutdown drain, bounded write
35
+ # backpressure, and single-owner bind under N workers.
36
+ #
37
+ # Non-goals in v1 (ADR-0102): no CRC/length framing helper beyond on_line, no
38
+ # TLS termination, no UDP, no replacement for the WebSocket surface.
39
+
40
+ # One live TCP connection — the uniform connection object (ADR-0102 D5). The
41
+ # SAME surface exists in all four frameworks so the primitive ports.
42
+ class TcpConnection
43
+ # The raw TCP peer address ("127.0.0.1:54321"), never a forwarded header.
44
+ attr_reader :remote_addr
45
+ # Per-connection mutable state bag — the app's parse buffer, auth flag.
46
+ attr_accessor :context
47
+ attr_reader :socket
48
+ # Stable registry key: the fd number captured at construction (see below).
49
+ attr_reader :fileno
50
+
51
+ def initialize(socket, service, remote_addr)
52
+ @socket = socket
53
+ # Cache the fd number now: once the socket is closed, #fileno raises
54
+ # IOError, and the registry still needs a stable key to remove by.
55
+ @fileno = begin
56
+ socket.fileno
57
+ rescue IOError, SystemCallError
58
+ nil
59
+ end
60
+ @service = service
61
+ @remote_addr = remote_addr
62
+ @id = nil # the registry id (IMEI), nil until identify
63
+ @context = {}
64
+ @closed = false
65
+ @close_fired = false
66
+ @last_activity = monotonic
67
+ @pending = +"".b
68
+ @line_buffer = +"".b
69
+ @write_lock = Mutex.new
70
+ end
71
+
72
+ # The registry id (the IMEI), or nil until identify is called.
73
+ def id
74
+ @id
75
+ end
76
+
77
+ def closed?
78
+ @closed
79
+ end
80
+
81
+ def last_activity
82
+ @last_activity
83
+ end
84
+
85
+ def touch
86
+ @last_activity = monotonic
87
+ end
88
+
89
+ # Accessors the service's reader loop uses for on_line buffering.
90
+ def line_buffer
91
+ @line_buffer
92
+ end
93
+
94
+ def line_buffer=(value)
95
+ @line_buffer = value
96
+ end
97
+
98
+ # Register this live connection under an app-chosen id (the IMEI). A second
99
+ # connection identifying with the same id SUPERSEDES the first in the
100
+ # registry — exactly a device that dropped and reconnected.
101
+ def identify(app_id)
102
+ @id = app_id.to_s
103
+ @service.register_id(@id, self)
104
+ self
105
+ end
106
+
107
+ # Write raw bytes to THIS socket. Returns true when queued/sent, false when
108
+ # the connection is closed or dropped for backpressure. A stalled reader
109
+ # (never draining) is bounded: once more than the service's max_backlog
110
+ # bytes are outstanding, the connection is dropped rather than buffered
111
+ # without end (mirrors the WebSocket TINA4_WS_MAX_BACKLOG posture).
112
+ def send(data)
113
+ return false if @closed
114
+
115
+ bytes = data.to_s.dup.force_encoding(Encoding::BINARY)
116
+ overflow = false
117
+ @write_lock.synchronize do
118
+ @pending << bytes
119
+ flush_pending
120
+ limit = @service.max_backlog
121
+ overflow = (limit.positive? && @pending.bytesize > limit)
122
+ end
123
+ return false if @closed
124
+
125
+ if overflow
126
+ Tina4::Log.warning(
127
+ "tcp_service '#{@service.name}': dropping a stalled reader — " \
128
+ "#{@pending.bytesize} bytes queued exceeds the #{@service.max_backlog}-byte backlog limit"
129
+ )
130
+ close
131
+ return false
132
+ end
133
+ true
134
+ end
135
+
136
+ # Flush the per-connection queue without blocking. Caller holds @write_lock.
137
+ def flush_pending
138
+ return if @socket.nil? || @pending.empty?
139
+
140
+ until @pending.empty?
141
+ written = @socket.write_nonblock(@pending, exception: false)
142
+ break if written == :wait_writable
143
+
144
+ @pending = @pending.byteslice(written..) || +"".b
145
+ end
146
+ rescue IOError, SystemCallError
147
+ @closed = true
148
+ close_socket
149
+ end
150
+
151
+ # Opportunistic flush from the reader loop (acquires the lock).
152
+ def try_flush
153
+ return if @closed
154
+
155
+ @write_lock.synchronize { flush_pending }
156
+ end
157
+
158
+ # Close this connection. on_close fires exactly once (from the service's
159
+ # removal path) and the registry entry is removed.
160
+ def close
161
+ return if @closed
162
+
163
+ @closed = true
164
+ close_socket
165
+ @service.remove(self)
166
+ end
167
+
168
+ # Fire on_close exactly once. Called by the service when the connection
169
+ # leaves (peer close, server close, reap, or drain).
170
+ def fire_close
171
+ return if @close_fired
172
+
173
+ @close_fired = true
174
+ @closed = true
175
+ handler = @service.callback(:on_close)
176
+ return if handler.nil?
177
+
178
+ begin
179
+ handler.call(self)
180
+ rescue StandardError => e
181
+ Tina4::Log.error("tcp_service '#{@service.name}' on_close error: #{e.message}")
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def close_socket
188
+ # Shut the socket down before closing so the peer sees EOF immediately.
189
+ # A plain close is enough on macOS, but on Linux the FIN can be deferred
190
+ # while the reader thread still holds the fd in IO.select, so the client
191
+ # never observes end-of-stream. shutdown(SHUT_RDWR) forces it.
192
+ @socket&.shutdown(Socket::SHUT_RDWR)
193
+ rescue IOError, SystemCallError
194
+ nil
195
+ ensure
196
+ begin
197
+ @socket&.close
198
+ rescue IOError, SystemCallError
199
+ nil
200
+ end
201
+ end
202
+
203
+ def monotonic
204
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
+ end
206
+ end
207
+
208
+ # A named raw TCP listener and its connection registry (ADR-0102).
209
+ class TcpService
210
+ READ_CHUNK = 65_536
211
+
212
+ attr_reader :name, :port
213
+
214
+ def initialize(port, name: "default", on_connect: nil, on_data: nil,
215
+ on_close: nil, on_line: nil, idle_timeout: 0,
216
+ max_connections: nil, max_backlog: nil, &block)
217
+ @port = Integer(port)
218
+ @name = name.to_s
219
+ @callbacks = {
220
+ on_connect: on_connect,
221
+ on_data: on_data || block, # a bare block is the on_data handler
222
+ on_close: on_close,
223
+ on_line: on_line
224
+ }
225
+ # Default OFF: tracker sockets are deliberately long-lived and mostly idle
226
+ # between location packets, so a default reaper would kill healthy devices.
227
+ @idle_timeout = idle_timeout.to_f
228
+ @max_connections = max_connections.nil? ? nil : Integer(max_connections)
229
+ @max_backlog = self.class.resolve_max_backlog(max_backlog)
230
+
231
+ @connections = {} # socket.fileno => TcpConnection
232
+ @by_id = {} # app_id => TcpConnection
233
+ @mutex = Mutex.new
234
+ @server = nil
235
+ @owner = false
236
+ @bound_port = nil
237
+ @acceptor = nil
238
+ @readers = {} # object_id => Thread
239
+ @running = false
240
+ end
241
+
242
+ def self.resolve_max_backlog(explicit)
243
+ default = 1 << 20 # 1 MiB
244
+ return (explicit.to_i.positive? ? explicit.to_i : default) unless explicit.nil?
245
+
246
+ raw = (ENV["TINA4_TCP_MAX_BACKLOG"] || "").strip
247
+ return default if raw.empty?
248
+
249
+ value = raw.to_i
250
+ value.positive? ? value : default
251
+ end
252
+
253
+ def max_backlog
254
+ @max_backlog
255
+ end
256
+
257
+ def callback(event)
258
+ @callbacks[event]
259
+ end
260
+
261
+ def owner?
262
+ @owner
263
+ end
264
+
265
+ def bound_port
266
+ @bound_port || @port
267
+ end
268
+
269
+ def count
270
+ @mutex.synchronize { @connections.size }
271
+ end
272
+
273
+ # ── Decorator-style setters (parity with the Python @svc.on_* handlers) ──
274
+
275
+ def on_connect(&block) = set_callback(:on_connect, block)
276
+ def on_data(&block) = set_callback(:on_data, block)
277
+ def on_close(&block) = set_callback(:on_close, block)
278
+ def on_line(&block) = set_callback(:on_line, block)
279
+
280
+ # ── Registry (D4 — server -> device push) ──────────────────────────────
281
+
282
+ def register_id(app_id, conn)
283
+ @mutex.synchronize { @by_id[app_id] = conn } # supersede: the live one wins
284
+ end
285
+
286
+ # The live connection for an app id, or nil if unknown or closed.
287
+ def connection(app_id)
288
+ conn = @mutex.synchronize { @by_id[app_id.to_s] }
289
+ return nil if conn.nil? || conn.closed?
290
+
291
+ conn
292
+ end
293
+
294
+ # Write bytes to exactly the connection identified by app_id. A send to an
295
+ # unknown or closed id is a NO-OP that returns false — never a raise.
296
+ def send_to(app_id, data)
297
+ conn = connection(app_id)
298
+ return false if conn.nil?
299
+
300
+ conn.send(data)
301
+ end
302
+
303
+ # Write bytes to every live connection. Returns how many received it.
304
+ def broadcast(data)
305
+ conns = @mutex.synchronize { @connections.values.dup }
306
+ conns.count { |conn| conn.send(data) }
307
+ end
308
+
309
+ # ── Lifecycle ────────────────────────────────────────────────────────────
310
+
311
+ # Bind the listener on host:port and start accepting on a dedicated thread.
312
+ # SO_REUSEPORT is OFF, so under a multi-worker deployment exactly ONE worker
313
+ # wins the bind; a worker that finds the port already bound skips silently
314
+ # (single-owner, D1). Returns true when THIS call bound the port.
315
+ def start(host = "0.0.0.0")
316
+ return @owner unless @server.nil?
317
+
318
+ begin
319
+ @server = TCPServer.new(host, @port)
320
+ rescue Errno::EADDRINUSE, Errno::EADDRNOTAVAIL => e
321
+ @owner = false
322
+ Tina4::Log.info(
323
+ "tcp_service '#{@name}' port #{@port} is already bound by another worker — " \
324
+ "single-owner, this worker will not bind (#{e.class})"
325
+ )
326
+ return false
327
+ end
328
+ @owner = true
329
+ @running = true
330
+ @bound_port = @server.addr[1]
331
+ Tina4::Log.info("tcp_service '#{@name}' listening on #{host}:#{@bound_port}")
332
+ @acceptor = Thread.new { accept_loop }
333
+ true
334
+ end
335
+
336
+ # Stop accepting new connections (close the listener). Live connections stay.
337
+ def stop_accepting
338
+ @running = false
339
+ srv = @server
340
+ @server = nil
341
+ srv&.close
342
+ rescue IOError, SystemCallError
343
+ nil
344
+ end
345
+
346
+ # Close every live connection (firing on_close) and release the listener.
347
+ def drain(timeout: 5.0)
348
+ stop_accepting
349
+ @acceptor&.join(timeout)
350
+ conns = @mutex.synchronize { @connections.values.dup }
351
+ conns.each(&:close)
352
+ readers = @mutex.synchronize { @readers.values.dup }
353
+ deadline = monotonic + timeout
354
+ readers.each do |thread|
355
+ remaining = deadline - monotonic
356
+ thread.join(remaining.positive? ? remaining : 0)
357
+ end
358
+ end
359
+
360
+ alias stop drain
361
+
362
+ # Remove a connection from the registry and fire on_close exactly once.
363
+ # Called by TcpConnection#close and by the reader loop on EOF.
364
+ def remove(conn)
365
+ tracked = false
366
+ @mutex.synchronize do
367
+ key = conn.fileno
368
+ if key && @connections[key].equal?(conn)
369
+ @connections.delete(key)
370
+ tracked = true
371
+ else
372
+ found = @connections.key(conn)
373
+ unless found.nil?
374
+ @connections.delete(found)
375
+ tracked = true
376
+ end
377
+ end
378
+ # Only drop the id when THIS connection still owns it; a newer
379
+ # connection that superseded it keeps the id.
380
+ @by_id.delete(conn.id) if conn.id && @by_id[conn.id].equal?(conn)
381
+ end
382
+ conn.fire_close if tracked
383
+ end
384
+
385
+ private
386
+
387
+ def set_callback(event, block)
388
+ @callbacks[event] = block if block
389
+ self
390
+ end
391
+
392
+ def accept_loop
393
+ while @running
394
+ begin
395
+ socket = @server&.accept
396
+ rescue IOError, Errno::EBADF, Errno::ECONNABORTED
397
+ break # listener closed by stop_accepting
398
+ end
399
+ break if socket.nil?
400
+
401
+ handle_accept(socket)
402
+ end
403
+ rescue StandardError => e
404
+ Tina4::Log.error("tcp_service '#{@name}' accept loop error: #{e.message}")
405
+ end
406
+
407
+ def handle_accept(socket)
408
+ # Refuse past the limit BEFORE the connection joins the registry.
409
+ over_limit = @mutex.synchronize do
410
+ !@max_connections.nil? && @connections.size >= @max_connections
411
+ end
412
+ if over_limit
413
+ socket.close rescue nil
414
+ return
415
+ end
416
+
417
+ conn = TcpConnection.new(socket, self, peer_address(socket))
418
+ @mutex.synchronize { @connections[conn.fileno] = conn }
419
+
420
+ fire(:on_connect, conn)
421
+
422
+ reader = Thread.new { reader_loop(conn) }
423
+ @mutex.synchronize { @readers[conn.object_id] = reader }
424
+ end
425
+
426
+ def reader_loop(conn)
427
+ socket = conn.socket
428
+ line_handler = @callbacks[:on_line]
429
+ sweep = @idle_timeout.positive? ? [@idle_timeout / 2.0, 1.0].min.clamp(0.05, 1.0) : nil
430
+ loop do
431
+ break if conn.closed?
432
+
433
+ ready = IO.select([socket], nil, nil, sweep)
434
+ if ready.nil?
435
+ # Idle sweep: close a connection silent past the timeout.
436
+ if @idle_timeout.positive? && (monotonic - conn.last_activity) >= @idle_timeout
437
+ conn.close
438
+ break
439
+ end
440
+ next
441
+ end
442
+
443
+ data = socket.read_nonblock(READ_CHUNK, exception: false)
444
+ next if data == :wait_readable
445
+ break if data.nil? # EOF — peer closed
446
+
447
+ conn.touch
448
+ dispatch_data(conn, data, line_handler)
449
+ conn.try_flush
450
+ end
451
+ rescue IOError, EOFError, Errno::ECONNRESET, Errno::EBADF
452
+ nil
453
+ rescue StandardError => e
454
+ Tina4::Log.error("tcp_service '#{@name}' handler error: #{e.message}")
455
+ ensure
456
+ @mutex.synchronize { @readers.delete(conn.object_id) }
457
+ remove(conn)
458
+ end
459
+
460
+ def dispatch_data(conn, data, line_handler)
461
+ on_data = @callbacks[:on_data]
462
+ # Raw bytes, exactly as received — never newline-split, never decoded.
463
+ if on_data
464
+ begin
465
+ on_data.call(conn, data)
466
+ rescue StandardError => e
467
+ Tina4::Log.error("tcp_service '#{@name}' on_data error: #{e.message}")
468
+ end
469
+ end
470
+
471
+ return unless line_handler
472
+
473
+ buffer = conn.line_buffer
474
+ buffer << data
475
+ while (index = buffer.index("\n"))
476
+ line = buffer.byteslice(0, index)
477
+ buffer = buffer.byteslice(index + 1..) || +"".b
478
+ line = line.chomp("\r")
479
+ begin
480
+ line_handler.call(conn, line)
481
+ rescue StandardError => e
482
+ Tina4::Log.error("tcp_service '#{@name}' on_line error: #{e.message}")
483
+ end
484
+ end
485
+ conn.line_buffer = buffer
486
+ end
487
+
488
+ def fire(event, conn)
489
+ handler = @callbacks[event]
490
+ return if handler.nil?
491
+
492
+ begin
493
+ handler.call(conn)
494
+ rescue StandardError => e
495
+ Tina4::Log.error("tcp_service '#{@name}' #{event} error: #{e.message}")
496
+ end
497
+ end
498
+
499
+ def peer_address(socket)
500
+ addr = socket.peeraddr(false)
501
+ "#{addr[3]}:#{addr[1]}"
502
+ rescue StandardError
503
+ ""
504
+ end
505
+
506
+ def monotonic
507
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
508
+ end
509
+ end
510
+
511
+ # Registry facade and the server-driven lifecycle hooks.
512
+ module Tcp
513
+ @services = {}
514
+ @mutex = Mutex.new
515
+
516
+ class << self
517
+ # Register a named raw TCP service; the server binds it on boot (D1).
518
+ def service(port, **options, &block)
519
+ name = (options[:name] || "default").to_s
520
+ svc = TcpService.new(port, **options, &block)
521
+ @mutex.synchronize { @services[name] = svc }
522
+ svc
523
+ end
524
+
525
+ # The registry for a named service, for server -> device push. Raises for
526
+ # an unknown service name — a programming error, unlike a send to an
527
+ # unknown device id, which is a runtime no-op.
528
+ def get(name = "default")
529
+ svc = @mutex.synchronize { @services[name.to_s] }
530
+ return svc unless svc.nil?
531
+
532
+ known = @services.keys.empty? ? "(none registered)" : @services.keys.join(", ")
533
+ raise KeyError, "no tcp_service named '#{name}' is registered; known services: #{known}"
534
+ end
535
+
536
+ def registered
537
+ @mutex.synchronize { @services.values.dup }
538
+ end
539
+
540
+ # Drop every registered service (test isolation; not used in production).
541
+ def clear_registry
542
+ @mutex.synchronize { @services = {} }
543
+ end
544
+
545
+ # Start every registered service on boot (called by the web server).
546
+ def start_all(host = "0.0.0.0")
547
+ registered.each do |svc|
548
+ svc.start(host)
549
+ rescue StandardError => e
550
+ Tina4::Log.error("tcp_service '#{svc.name}' failed to start: #{e.message}")
551
+ end
552
+ end
553
+
554
+ # Stop every service accepting (shutdown step 1).
555
+ def stop_accepting_all
556
+ registered.each do |svc|
557
+ svc.stop_accepting
558
+ rescue StandardError => e
559
+ Tina4::Log.error("tcp_service '#{svc.name}' stop-accepting failed: #{e.message}")
560
+ end
561
+ end
562
+
563
+ # Drain and release every service's connections (shutdown drain step).
564
+ def drain_all(timeout: 5.0)
565
+ registered.each do |svc|
566
+ svc.drain(timeout: timeout)
567
+ rescue StandardError => e
568
+ Tina4::Log.error("tcp_service '#{svc.name}' drain failed: #{e.message}")
569
+ end
570
+ end
571
+ end
572
+ end
573
+ end
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.157"
10
+ VERSION = "3.13.158"
11
11
  end
@@ -130,6 +130,11 @@ module Tina4
130
130
  # rescue stays out of #start's cyclomatic complexity.
131
131
  bind_auxiliary_ai_port
132
132
 
133
+ # Raw TCP services (feature 142, ADR-0102 D1) run their acceptor threads in
134
+ # THIS process, beside the HTTP server — one process serves both the device
135
+ # gateway and its dashboard. Shutdown drains them (see Tina4::Shutdown).
136
+ Tina4::Tcp.start_all(@host)
137
+
133
138
  @server.start
134
139
 
135
140
  # Shutdown closes the listener FIRST, so #start returns as soon as the
data/lib/tina4.rb CHANGED
@@ -48,6 +48,7 @@ require_relative "tina4/localization"
48
48
  require_relative "tina4/container"
49
49
  require_relative "tina4/service"
50
50
  require_relative "tina4/service_runner"
51
+ require_relative "tina4/tcp"
51
52
  require_relative "tina4/events"
52
53
  require_relative "tina4/plan"
53
54
  require_relative "tina4/project_index"
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.157
4
+ version: 3.13.158
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-11-02 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -354,6 +354,7 @@ files:
354
354
  - lib/tina4/sso.rb
355
355
  - lib/tina4/ssrf.rb
356
356
  - lib/tina4/swagger.rb
357
+ - lib/tina4/tcp.rb
357
358
  - lib/tina4/template.rb
358
359
  - lib/tina4/templates/base.twig
359
360
  - lib/tina4/templates/crud/form.twig