tina4ruby 3.13.103 → 3.13.105
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 +4 -4
- data/CHANGELOG.md +20 -0
- data/lib/tina4/cli.rb +7 -4
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/orm.rb +75 -11
- data/lib/tina4/point.rb +106 -0
- data/lib/tina4/query_builder.rb +66 -6
- data/lib/tina4/queue.rb +36 -6
- data/lib/tina4/queue_backends/lite_backend.rb +18 -1
- data/lib/tina4/queue_backends/mongo_backend.rb +38 -10
- data/lib/tina4/rack_app.rb +6 -0
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/sql_translator.rb +63 -0
- data/lib/tina4/sso.rb +284 -0
- data/lib/tina4/swagger.rb +16 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +6 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ab6d7ce15da93e814204665a8690be0cc2ac58dc1152fa75f050b91fcb0c4f0
|
|
4
|
+
data.tar.gz: 35fc27eb9d71c17a0049d983d34ed6c1584e014338192e5ace087e5e2bec0746
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2e66595ddcf7eca7d498428149ca38a83caf4871d8d5e29e31ef9d72f7f38f2ed9c3afe638ba2891bf9ffda5b00dbbf54883ddc704bf06787f9f06060b0603b
|
|
7
|
+
data.tar.gz: c45ab7892609d2be7e0b3dc432a8d54bef9dd8281e3594198b9fa93e9170ceeb60372624c69d49978884ef596cb7ff35eeb720216c4d240a176e372ffa33d803
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ 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.105
|
|
10
|
+
|
|
11
|
+
Bug release. Route inspection stops touching the app; Firebird's migration
|
|
12
|
+
ledger tolerates whatever case the driver hands back; PHP loses a colon-in-
|
|
13
|
+
filename that broke Windows checkouts.
|
|
14
|
+
|
|
15
|
+
### Route inspection scans, never boots
|
|
16
|
+
|
|
17
|
+
- `tina4 routes` now walks canonical route files and never executes the
|
|
18
|
+
application entrypoint or starts the server. Feature 115 / ADR-0058.
|
|
19
|
+
- Fixes the case where `tina4 routes --override` would boot the app on the
|
|
20
|
+
same port and kill whatever process was already holding it (tina4-python
|
|
21
|
+
issue #104).
|
|
22
|
+
|
|
23
|
+
### Firebird migration ledger is case-agnostic
|
|
24
|
+
|
|
25
|
+
- `tina4_migration` reads and writes work regardless of the case the
|
|
26
|
+
Firebird driver returns for the `migration_name` column.
|
|
27
|
+
- Uses the atomic sequence table pattern already in place for other engines.
|
|
28
|
+
|
|
9
29
|
## 3.13.103
|
|
10
30
|
|
|
11
31
|
### Metrics reports what it can prove
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -1013,8 +1013,8 @@ module Tina4
|
|
|
1013
1013
|
|
|
1014
1014
|
def cmd_routes(_argv = nil)
|
|
1015
1015
|
require_relative "../tina4"
|
|
1016
|
-
Tina4.
|
|
1017
|
-
load_routes(Dir.pwd)
|
|
1016
|
+
Tina4::Env.load_env(Dir.pwd)
|
|
1017
|
+
load_routes(Dir.pwd, entrypoints: false)
|
|
1018
1018
|
|
|
1019
1019
|
puts "\nRegistered Routes:"
|
|
1020
1020
|
puts "-" * 60
|
|
@@ -2959,7 +2959,7 @@ module Tina4
|
|
|
2959
2959
|
|
|
2960
2960
|
# ── shared helpers ────────────────────────────────────────────────────
|
|
2961
2961
|
|
|
2962
|
-
def load_routes(root_dir)
|
|
2962
|
+
def load_routes(root_dir, entrypoints: true)
|
|
2963
2963
|
route_dirs = %w[src/routes routes src/api api src/orm orm]
|
|
2964
2964
|
route_dirs.each do |dir|
|
|
2965
2965
|
route_dir = File.join(root_dir, dir)
|
|
@@ -2967,7 +2967,10 @@ module Tina4
|
|
|
2967
2967
|
Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
|
|
2968
2968
|
end
|
|
2969
2969
|
|
|
2970
|
-
|
|
2970
|
+
return unless entrypoints
|
|
2971
|
+
|
|
2972
|
+
# Runtime and console boot load the application entrypoint. Inspection
|
|
2973
|
+
# commands opt out so they can never start a server or take over a port.
|
|
2971
2974
|
app_file = File.join(root_dir, "app.rb")
|
|
2972
2975
|
load app_file if File.exist?(app_file)
|
|
2973
2976
|
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -108,6 +108,11 @@ module Tina4
|
|
|
108
108
|
register_field(name, :json, nullable: nullable, default: default)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
def point_field(name, srid: Tina4::Point::DEFAULT_SRID, spatial_index: true, nullable: true, default: nil)
|
|
112
|
+
register_field(name, :point, srid: Integer(srid), spatial_index: spatial_index,
|
|
113
|
+
nullable: nullable, default: default)
|
|
114
|
+
end
|
|
115
|
+
|
|
111
116
|
# Declare a foreign key integer column and auto-wire relationships.
|
|
112
117
|
#
|
|
113
118
|
# Automatically:
|
|
@@ -230,6 +235,13 @@ module Tina4
|
|
|
230
235
|
# __setattr__ + object.__setattr__ default-seeding.
|
|
231
236
|
attr_reader name
|
|
232
237
|
define_method("#{name}=") do |value|
|
|
238
|
+
if type == :point && !value.nil?
|
|
239
|
+
point = Tina4::Point.parse(value, srid: options[:srid])
|
|
240
|
+
if point.srid != options[:srid]
|
|
241
|
+
raise ArgumentError, "Point field '#{name}' expects SRID #{options[:srid]}; received #{point.srid}"
|
|
242
|
+
end
|
|
243
|
+
value = point
|
|
244
|
+
end
|
|
233
245
|
fields = (@assigned_fields ||= [])
|
|
234
246
|
fields << name unless fields.include?(name)
|
|
235
247
|
instance_variable_set("@#{name}", value)
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -245,7 +245,7 @@ module Tina4
|
|
|
245
245
|
#
|
|
246
246
|
# @return [Tina4::QueryBuilder]
|
|
247
247
|
def query
|
|
248
|
-
QueryBuilder.from_table(table_name, db: db)
|
|
248
|
+
QueryBuilder.from_table(table_name, db: db, primary_key: get_db_column(primary_key_field || :id))
|
|
249
249
|
end
|
|
250
250
|
|
|
251
251
|
def find(id_or_filter = nil, filter = nil, **kwargs)
|
|
@@ -480,13 +480,25 @@ module Tina4
|
|
|
480
480
|
|
|
481
481
|
# Invalidate every cached query that touches this model's table.
|
|
482
482
|
#
|
|
483
|
-
# Tag-scoped
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
483
|
+
# Tag-scoped in the ORM layer (a cached JOIN on another model that reads
|
|
484
|
+
# this table is busted too because it carries this table's tag; a query
|
|
485
|
+
# that never touches this table is left intact), then cascaded to the
|
|
486
|
+
# DB layer on this model's bound connection so an out-of-band write /
|
|
487
|
+
# deliberate refresh / race-with-another-process cannot leave stale rows
|
|
488
|
+
# in db.fetch's persistent cache. Called after every ORM write
|
|
489
|
+
# (save/delete/force_delete/restore) so a read-after-write never serves
|
|
490
|
+
# a stale/deleted row (CACHE-DEC-01). 3.13.105 (parity port of PY-06-22)
|
|
491
|
+
# added the DB-layer cascade — previously the two cache layers disagreed
|
|
492
|
+
# under TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
|
|
488
493
|
def clear_cache
|
|
489
494
|
query_cache.clear_tag(table_name.to_s.downcase)
|
|
495
|
+
begin
|
|
496
|
+
get_db.cache_clear
|
|
497
|
+
rescue StandardError
|
|
498
|
+
# A resolvable DB is not guaranteed at every clear_cache call site
|
|
499
|
+
# (module-import time in odd bootstraps, tests that mutate bindings);
|
|
500
|
+
# never let a cache-clear crash a save/delete.
|
|
501
|
+
end
|
|
490
502
|
nil
|
|
491
503
|
end
|
|
492
504
|
|
|
@@ -529,7 +541,7 @@ module Tina4
|
|
|
529
541
|
end
|
|
530
542
|
|
|
531
543
|
def create_table
|
|
532
|
-
return
|
|
544
|
+
return create_spatial_indexes if db.table_exists?(table_name)
|
|
533
545
|
|
|
534
546
|
# v3.13.16: engine-aware DDL. Ruby used to emit SQLite-only DDL on
|
|
535
547
|
# every driver — INTEGER for booleans, DATETIME for datetimes, and a
|
|
@@ -594,12 +606,17 @@ module Tina4
|
|
|
594
606
|
datetime: datetime_sql,
|
|
595
607
|
timestamp: "TIMESTAMP",
|
|
596
608
|
blob: "BLOB",
|
|
597
|
-
json: json_sql
|
|
609
|
+
json: json_sql,
|
|
610
|
+
point: nil
|
|
598
611
|
}
|
|
599
612
|
|
|
600
613
|
col_defs = []
|
|
601
614
|
field_definitions.each do |name, opts|
|
|
602
|
-
sql_type =
|
|
615
|
+
sql_type = if opts[:type] == :point
|
|
616
|
+
SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
|
|
617
|
+
else
|
|
618
|
+
type_map[opts[:type]] || "TEXT"
|
|
619
|
+
end
|
|
603
620
|
if opts[:type] == :string && opts[:length]
|
|
604
621
|
sql_type = "VARCHAR(#{opts[:length]})"
|
|
605
622
|
elsif opts[:type] == :decimal
|
|
@@ -687,13 +704,35 @@ module Tina4
|
|
|
687
704
|
# ONE case is benign - re-raise anything else.
|
|
688
705
|
raise ce unless ce.message.to_s =~ /no corresponding begin/i
|
|
689
706
|
end
|
|
690
|
-
|
|
707
|
+
create_spatial_indexes
|
|
708
|
+
rescue SpatialNotSupportedError
|
|
709
|
+
raise
|
|
691
710
|
rescue => e
|
|
692
711
|
Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
|
|
693
712
|
false
|
|
694
713
|
end
|
|
695
714
|
end
|
|
696
715
|
|
|
716
|
+
def create_spatial_indexes
|
|
717
|
+
fields = field_definitions.select { |_name, opts| opts[:type] == :point }
|
|
718
|
+
return true if fields.empty?
|
|
719
|
+
engine = db.get_database_type.to_s.downcase
|
|
720
|
+
begin
|
|
721
|
+
fields.each do |name, opts|
|
|
722
|
+
SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
|
|
723
|
+
next unless opts.fetch(:spatial_index, true)
|
|
724
|
+
db.execute(SQLTranslator.spatial_index(engine, table_name, get_db_column(name)))
|
|
725
|
+
end
|
|
726
|
+
db.commit
|
|
727
|
+
true
|
|
728
|
+
rescue SpatialNotSupportedError
|
|
729
|
+
raise
|
|
730
|
+
rescue => e
|
|
731
|
+
Tina4::Log.error("spatial index creation failed for #{table_name}: #{e.message}")
|
|
732
|
+
false
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
697
736
|
def scope(name, filter_sql, params = [])
|
|
698
737
|
define_singleton_method(name) do |limit: 100, offset: 0|
|
|
699
738
|
where(filter_sql, params, limit: limit, offset: offset)
|
|
@@ -721,6 +760,9 @@ module Tina4
|
|
|
721
760
|
# leave the raw string in place
|
|
722
761
|
end
|
|
723
762
|
end
|
|
763
|
+
if fdef && fdef[:type] == :point && !value.nil?
|
|
764
|
+
value = Point.parse(value, srid: fdef[:srid] || Point::DEFAULT_SRID)
|
|
765
|
+
end
|
|
724
766
|
setter = "#{attr_name}="
|
|
725
767
|
instance.__send__(setter, value) if instance.respond_to?(setter)
|
|
726
768
|
end
|
|
@@ -857,6 +899,12 @@ module Tina4
|
|
|
857
899
|
# a.meta must not leak into b.meta). Parity with the Python master,
|
|
858
900
|
# which deepcopies a JSONField's dict/list default per instance.
|
|
859
901
|
d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
|
|
902
|
+
if opts[:type] == :point && !d.nil?
|
|
903
|
+
d = Point.parse(d, srid: opts[:srid] || Point::DEFAULT_SRID)
|
|
904
|
+
if d.srid != (opts[:srid] || Point::DEFAULT_SRID)
|
|
905
|
+
raise ArgumentError, "Point field '#{name}' expects SRID #{opts[:srid] || Point::DEFAULT_SRID}; received #{d.srid}"
|
|
906
|
+
end
|
|
907
|
+
end
|
|
860
908
|
# #165: seed the default straight into the ivar, BYPASSING the
|
|
861
909
|
# tracking setter, so a default is not recorded as a caller
|
|
862
910
|
# assignment (mirrors the Python master's object.__setattr__).
|
|
@@ -1283,7 +1331,8 @@ module Tina4
|
|
|
1283
1331
|
key_case = binding.local_variable_get(:case) # :case is a reserved word
|
|
1284
1332
|
hash = {}
|
|
1285
1333
|
self.class.field_definitions.each_key do |name|
|
|
1286
|
-
|
|
1334
|
+
value = __send__(name)
|
|
1335
|
+
hash[name] = value.is_a?(Point) ? value.geojson : value
|
|
1287
1336
|
end
|
|
1288
1337
|
|
|
1289
1338
|
if include
|
|
@@ -1321,6 +1370,19 @@ module Tina4
|
|
|
1321
1370
|
hash
|
|
1322
1371
|
end
|
|
1323
1372
|
|
|
1373
|
+
def to_feature(geometry_field: nil, include: nil)
|
|
1374
|
+
point_fields = self.class.field_definitions.select { |_name, opts| opts[:type] == :point }.keys
|
|
1375
|
+
geometry_field = (geometry_field || point_fields.first)&.to_sym
|
|
1376
|
+
raise ArgumentError, "to_feature needs a declared point_field" unless geometry_field && point_fields.include?(geometry_field)
|
|
1377
|
+
properties = to_h(include: include)
|
|
1378
|
+
geometry = properties.delete(geometry_field)
|
|
1379
|
+
{ type: "Feature", geometry: geometry, properties: properties }
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
def self.feature_collection(models, geometry_field: nil, include: nil)
|
|
1383
|
+
{ type: "FeatureCollection", features: models.map { |model| model.to_feature(geometry_field: geometry_field, include: include) } }
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1324
1386
|
alias to_hash to_h
|
|
1325
1387
|
alias to_dict to_h
|
|
1326
1388
|
alias to_object to_h
|
|
@@ -1377,6 +1439,7 @@ module Tina4
|
|
|
1377
1439
|
if opts[:type] == :json && !value.nil? && !value.is_a?(String)
|
|
1378
1440
|
value = JSON.generate(value)
|
|
1379
1441
|
end
|
|
1442
|
+
value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
|
|
1380
1443
|
db_col = mapping[name.to_s] || name
|
|
1381
1444
|
hash[db_col.to_sym] = value
|
|
1382
1445
|
end
|
|
@@ -1400,6 +1463,7 @@ module Tina4
|
|
|
1400
1463
|
if opts[:type] == :json && !value.nil? && !value.is_a?(String)
|
|
1401
1464
|
value = JSON.generate(value)
|
|
1402
1465
|
end
|
|
1466
|
+
value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
|
|
1403
1467
|
db_col = mapping[name.to_s] || name
|
|
1404
1468
|
hash[db_col.to_sym] = value
|
|
1405
1469
|
end
|
data/lib/tina4/point.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
class SpatialNotSupportedError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Immutable SRID-aware longitude/latitude point (ADR-0057).
|
|
9
|
+
class Point
|
|
10
|
+
DEFAULT_SRID = 4326
|
|
11
|
+
EWKB_SRID = 0x20000000
|
|
12
|
+
EWKB_M = 0x40000000
|
|
13
|
+
EWKB_Z = 0x80000000
|
|
14
|
+
|
|
15
|
+
attr_reader :lon, :lat, :srid
|
|
16
|
+
|
|
17
|
+
def initialize(lon, lat, srid: DEFAULT_SRID)
|
|
18
|
+
raise ArgumentError, "Point longitude and latitude must be numbers" if lon == true || lon == false || lat == true || lat == false
|
|
19
|
+
@lon = Float(lon)
|
|
20
|
+
@lat = Float(lat)
|
|
21
|
+
@srid = Integer(srid)
|
|
22
|
+
raise ArgumentError, "Point longitude and latitude must be finite" unless @lon.finite? && @lat.finite?
|
|
23
|
+
if @srid == DEFAULT_SRID
|
|
24
|
+
raise ArgumentError, "Point longitude #{@lon} is outside -180..180; Tina4 uses longitude, latitude order" unless (-180.0..180.0).cover?(@lon)
|
|
25
|
+
raise ArgumentError, "Point latitude #{@lat} is outside -90..90; Tina4 uses longitude, latitude order" unless (-90.0..90.0).cover?(@lat)
|
|
26
|
+
end
|
|
27
|
+
freeze
|
|
28
|
+
rescue TypeError, ArgumentError => e
|
|
29
|
+
raise e if e.message.start_with?("Point ")
|
|
30
|
+
raise ArgumentError, "Point longitude, latitude and SRID must be numeric"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wkt = "POINT(#{format_coordinate(@lon)} #{format_coordinate(@lat)})"
|
|
34
|
+
def ewkt = "SRID=#{@srid};#{wkt}"
|
|
35
|
+
def geojson = { type: "Point", coordinates: [@lon, @lat] }
|
|
36
|
+
def to_h = geojson
|
|
37
|
+
def to_a = [@lon, @lat]
|
|
38
|
+
def to_json(*args) = geojson.to_json(*args)
|
|
39
|
+
|
|
40
|
+
def self.parse(value, srid: DEFAULT_SRID)
|
|
41
|
+
return value if value.is_a?(Point)
|
|
42
|
+
if value.is_a?(Array)
|
|
43
|
+
raise ArgumentError, "Point coordinate pair needs longitude and latitude" if value.length < 2
|
|
44
|
+
return new(value[0], value[1], srid: srid)
|
|
45
|
+
end
|
|
46
|
+
return from_geojson(value, srid) if value.is_a?(Hash)
|
|
47
|
+
if value.is_a?(String)
|
|
48
|
+
text = value.strip
|
|
49
|
+
match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)\z/i)
|
|
50
|
+
return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
|
|
51
|
+
raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
|
|
52
|
+
raw ||= value.b if [0, 1].include?(value.getbyte(0))
|
|
53
|
+
return from_wkb(raw, srid) if raw
|
|
54
|
+
end
|
|
55
|
+
raise ArgumentError, "Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.geometry_binding(value, srid: DEFAULT_SRID)
|
|
59
|
+
return [parse(value, srid: srid).ewkt, :ewkt] if value.is_a?(Point) || value.is_a?(Array)
|
|
60
|
+
if value.is_a?(Hash)
|
|
61
|
+
geometry = value.fetch(:type, value["type"]).to_s.downcase == "feature" ? (value[:geometry] || value["geometry"]) : value
|
|
62
|
+
type = (geometry[:type] || geometry["type"]).to_s.downcase
|
|
63
|
+
allowed = %w[point linestring polygon multipoint multilinestring multipolygon geometrycollection]
|
|
64
|
+
raise ArgumentError, "GeoJSON geometry has an unsupported type" unless allowed.include?(type)
|
|
65
|
+
return [JSON.generate(geometry), :geojson]
|
|
66
|
+
end
|
|
67
|
+
if value.is_a?(String) && value.match?(/\A\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i)
|
|
68
|
+
return [value.match?(/\A\s*SRID/i) ? value.strip : "SRID=#{srid};#{value.strip}", :ewkt]
|
|
69
|
+
end
|
|
70
|
+
raise ArgumentError, "Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.from_geojson(data, srid)
|
|
74
|
+
type = (data[:type] || data["type"]).to_s.downcase
|
|
75
|
+
geometry = type == "feature" ? (data[:geometry] || data["geometry"] || {}) : data
|
|
76
|
+
raise ArgumentError, "Point GeoJSON type must be Point" unless (geometry[:type] || geometry["type"]).to_s.downcase == "point"
|
|
77
|
+
coordinates = geometry[:coordinates] || geometry["coordinates"]
|
|
78
|
+
raise ArgumentError, "Point GeoJSON coordinates must be [longitude, latitude]" unless coordinates.is_a?(Array) && coordinates.length >= 2
|
|
79
|
+
new(coordinates[0], coordinates[1], srid: srid)
|
|
80
|
+
end
|
|
81
|
+
private_class_method :from_geojson
|
|
82
|
+
|
|
83
|
+
def self.from_wkb(raw, srid)
|
|
84
|
+
raise ArgumentError, "Point WKB is too short" if raw.bytesize < 21
|
|
85
|
+
little = raw.getbyte(0) == 1
|
|
86
|
+
type_word = raw.byteslice(1, 4).unpack1(little ? "V" : "N")
|
|
87
|
+
offset = 5
|
|
88
|
+
if (type_word & EWKB_SRID) != 0
|
|
89
|
+
srid = raw.byteslice(5, 4).unpack1(little ? "V" : "N")
|
|
90
|
+
offset = 9
|
|
91
|
+
end
|
|
92
|
+
code = (type_word & ~(EWKB_SRID | EWKB_Z | EWKB_M)) % 1000
|
|
93
|
+
raise ArgumentError, "WKB geometry is not a Point" unless code == 1 && raw.bytesize >= offset + 16
|
|
94
|
+
lon, lat = raw.byteslice(offset, 16).unpack(little ? "E2" : "G2")
|
|
95
|
+
new(lon, lat, srid: srid)
|
|
96
|
+
end
|
|
97
|
+
private_class_method :from_wkb
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def format_coordinate(value)
|
|
102
|
+
text = format("%.15g", value)
|
|
103
|
+
text == "-0" ? "0" : text
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -19,10 +19,11 @@ module Tina4
|
|
|
19
19
|
# .get
|
|
20
20
|
#
|
|
21
21
|
class QueryBuilder
|
|
22
|
-
def initialize(table, db: nil)
|
|
22
|
+
def initialize(table, db: nil, primary_key: nil)
|
|
23
23
|
@table = table
|
|
24
24
|
@db = db
|
|
25
25
|
@columns = ["*"]
|
|
26
|
+
@select_params = []
|
|
26
27
|
@wheres = []
|
|
27
28
|
@params = []
|
|
28
29
|
@joins = []
|
|
@@ -30,6 +31,8 @@ module Tina4
|
|
|
30
31
|
@havings = []
|
|
31
32
|
@having_params = []
|
|
32
33
|
@order_by_cols = []
|
|
34
|
+
@order_by_params = []
|
|
35
|
+
@primary_key = primary_key&.to_s
|
|
33
36
|
@limit_val = nil
|
|
34
37
|
@offset_val = nil
|
|
35
38
|
end
|
|
@@ -39,8 +42,8 @@ module Tina4
|
|
|
39
42
|
# @param table_name [String] The database table name.
|
|
40
43
|
# @param db [Object, nil] Optional database connection.
|
|
41
44
|
# @return [QueryBuilder]
|
|
42
|
-
def self.from_table(table_name, db: nil)
|
|
43
|
-
new(table_name, db: db)
|
|
45
|
+
def self.from_table(table_name, db: nil, primary_key: nil)
|
|
46
|
+
new(table_name, db: db, primary_key: primary_key)
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
# Set the columns to select.
|
|
@@ -48,7 +51,10 @@ module Tina4
|
|
|
48
51
|
# @param columns [Array<String>] Column names.
|
|
49
52
|
# @return [self]
|
|
50
53
|
def select(*columns)
|
|
51
|
-
|
|
54
|
+
unless columns.empty?
|
|
55
|
+
@columns = columns
|
|
56
|
+
@select_params = []
|
|
57
|
+
end
|
|
52
58
|
self
|
|
53
59
|
end
|
|
54
60
|
|
|
@@ -123,6 +129,46 @@ module Tina4
|
|
|
123
129
|
self
|
|
124
130
|
end
|
|
125
131
|
|
|
132
|
+
def within_distance(column, point, radius_metres, srid: Point::DEFAULT_SRID)
|
|
133
|
+
radius = Float(radius_metres)
|
|
134
|
+
raise ArgumentError, "Spatial radius must be finite and greater than or equal to zero" unless radius.finite? && radius >= 0
|
|
135
|
+
point = Point.parse(point, srid: srid)
|
|
136
|
+
where(SQLTranslator.within_distance(engine, column, point.srid), [point.lon, point.lat, radius])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def intersects(column, geometry, srid: Point::DEFAULT_SRID)
|
|
140
|
+
bound, form = Point.geometry_binding(geometry, srid: srid)
|
|
141
|
+
where(SQLTranslator.intersects(engine, column, form, srid), [bound])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def bbox(column, min_lon, min_lat, max_lon, max_lat, srid: Point::DEFAULT_SRID)
|
|
145
|
+
values = [min_lon, min_lat, max_lon, max_lat].map { |value| Float(value) }
|
|
146
|
+
raise ArgumentError, "Bounding-box coordinates must be finite" unless values.all?(&:finite?)
|
|
147
|
+
west, south, east, north = values
|
|
148
|
+
Point.new(west, south, srid: srid)
|
|
149
|
+
Point.new(east, north, srid: srid)
|
|
150
|
+
raise ArgumentError, "Bounding box must be ordered west, south, east, north" if west > east || south > north
|
|
151
|
+
where(SQLTranslator.bbox(engine, column, srid), values)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def select_distance(column, point, alias_name: "distance", srid: Point::DEFAULT_SRID)
|
|
155
|
+
point = Point.parse(point, srid: srid)
|
|
156
|
+
@columns << SQLTranslator.distance_as(engine, column, alias_name, point.srid)
|
|
157
|
+
@select_params.concat([point.lon, point.lat])
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def order_by_distance(column, point, direction: "ASC", srid: Point::DEFAULT_SRID)
|
|
162
|
+
direction = direction.to_s.upcase
|
|
163
|
+
raise ArgumentError, "Distance order direction must be ASC or DESC" unless %w[ASC DESC].include?(direction)
|
|
164
|
+
raise ArgumentError, "Stable spatial ordering needs a primary key; use ORM.query or pass primary_key:" if @primary_key.to_s.empty?
|
|
165
|
+
point = Point.parse(point, srid: srid)
|
|
166
|
+
@order_by_cols << "#{SQLTranslator.distance(engine, column, point.srid)} #{direction}"
|
|
167
|
+
@order_by_params.concat([point.lon, point.lat])
|
|
168
|
+
@order_by_cols << "#{SQLTranslator.spatial_identifier(@primary_key, 'primary key')} ASC"
|
|
169
|
+
self
|
|
170
|
+
end
|
|
171
|
+
|
|
126
172
|
# Set LIMIT and optional OFFSET.
|
|
127
173
|
#
|
|
128
174
|
# @param count [Integer] Maximum rows to return.
|
|
@@ -167,7 +213,7 @@ module Tina4
|
|
|
167
213
|
def get
|
|
168
214
|
ensure_db!
|
|
169
215
|
sql = to_sql
|
|
170
|
-
all_params = @params + @having_params
|
|
216
|
+
all_params = @select_params + @params + @having_params + @order_by_params
|
|
171
217
|
|
|
172
218
|
@db.fetch(
|
|
173
219
|
sql,
|
|
@@ -183,7 +229,7 @@ module Tina4
|
|
|
183
229
|
def first
|
|
184
230
|
ensure_db!
|
|
185
231
|
sql = to_sql
|
|
186
|
-
all_params = @params + @having_params
|
|
232
|
+
all_params = @select_params + @params + @having_params + @order_by_params
|
|
187
233
|
|
|
188
234
|
@db.fetch_one(sql, all_params.empty? ? [] : all_params)
|
|
189
235
|
end
|
|
@@ -196,9 +242,18 @@ module Tina4
|
|
|
196
242
|
|
|
197
243
|
# Build a count query by replacing columns
|
|
198
244
|
original = @columns
|
|
245
|
+
original_select_params = @select_params
|
|
246
|
+
original_order = @order_by_cols
|
|
247
|
+
original_order_params = @order_by_params
|
|
199
248
|
@columns = ["COUNT(*) as cnt"]
|
|
249
|
+
@select_params = []
|
|
250
|
+
@order_by_cols = []
|
|
251
|
+
@order_by_params = []
|
|
200
252
|
sql = to_sql
|
|
201
253
|
@columns = original
|
|
254
|
+
@select_params = original_select_params
|
|
255
|
+
@order_by_cols = original_order
|
|
256
|
+
@order_by_params = original_order_params
|
|
202
257
|
|
|
203
258
|
all_params = @params + @having_params
|
|
204
259
|
|
|
@@ -272,6 +327,11 @@ module Tina4
|
|
|
272
327
|
|
|
273
328
|
private
|
|
274
329
|
|
|
330
|
+
def engine
|
|
331
|
+
ensure_db!
|
|
332
|
+
@db.get_database_type
|
|
333
|
+
end
|
|
334
|
+
|
|
275
335
|
# Parse a single SQL condition into a MongoDB filter hash.
|
|
276
336
|
#
|
|
277
337
|
# @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
|
data/lib/tina4/queue.rb
CHANGED
|
@@ -171,7 +171,14 @@ module Tina4
|
|
|
171
171
|
@backend.close
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
# Get jobs that failed but are still
|
|
174
|
+
# Get jobs that failed at least once but are still being retried
|
|
175
|
+
# (0 < attempts < max_retries). These live in the pending queue under
|
|
176
|
+
# the auto-retry lifecycle (fail() re-queues them with an incremented
|
|
177
|
+
# attempts count and a retry_backoff delay) so pop() picks them up
|
|
178
|
+
# again. They are NOT counted by size("failed") — that alias counts
|
|
179
|
+
# the dead-letter store, matching dead_letters(). To include retryable-
|
|
180
|
+
# failed jobs in a total, use size("pending"). Terminal failures are
|
|
181
|
+
# returned by dead_letters().
|
|
175
182
|
def failed # -> list[dict]
|
|
176
183
|
refuse_operation!("failed()") unless @backend.respond_to?(:failed)
|
|
177
184
|
@backend.failed(@topic, max_retries: @max_retries)
|
|
@@ -179,13 +186,24 @@ module Tina4
|
|
|
179
186
|
|
|
180
187
|
# Retry a specific failed job by ID, or all dead-letter jobs if no id given.
|
|
181
188
|
# Returns true if re-queued.
|
|
189
|
+
#
|
|
190
|
+
# The no-arg branch is materialised by the backend (LiteBackend#retry_job
|
|
191
|
+
# iterates every dead-letter file with .each, MongoBackend materialises
|
|
192
|
+
# find(...).to_a before iterating) — no generator inside any() /
|
|
193
|
+
# short-circuit reduce can silently leave dead letters behind (parity port
|
|
194
|
+
# of PY-12-04, 3.13.105).
|
|
182
195
|
def retry(job_id = nil, delay_seconds: 0) # -> bool
|
|
183
196
|
refuse_operation!("retry()") unless @backend.respond_to?(:retry_job)
|
|
184
197
|
@backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
|
|
185
198
|
end
|
|
186
199
|
|
|
187
|
-
# Get
|
|
188
|
-
#
|
|
200
|
+
# Get jobs that exceeded max_retries — terminal failures.
|
|
201
|
+
#
|
|
202
|
+
# Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
203
|
+
# (three aliases for the dead-letter store). To LIST retryable-but-
|
|
204
|
+
# attempted jobs (attempts > 0 AND attempts < max_retries) that are still
|
|
205
|
+
# being auto-retried, use failed() — those live in the pending queue and
|
|
206
|
+
# are NOT dead letters. Pass max_retries to override the queue's default.
|
|
189
207
|
def dead_letters(max_retries: nil) # -> list[dict]
|
|
190
208
|
refuse_operation!("dead_letters()") unless @backend.respond_to?(:dead_letters)
|
|
191
209
|
@backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
|
|
@@ -328,9 +346,21 @@ module Tina4
|
|
|
328
346
|
attach(@backend.find_by_id(topic || @topic, id))
|
|
329
347
|
end
|
|
330
348
|
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
349
|
+
# Count jobs by status.
|
|
350
|
+
#
|
|
351
|
+
# "pending" counts jobs waiting to be popped — INCLUDES retryable-but-
|
|
352
|
+
# attempted ones, because they live in the pending queue under the
|
|
353
|
+
# auto-retry lifecycle (see failed()).
|
|
354
|
+
# "reserved" counts jobs a consumer has popped but not yet
|
|
355
|
+
# completed/failed (in-flight against the visibility timeout).
|
|
356
|
+
# "completed" counts jobs the consumer has finished successfully
|
|
357
|
+
# (0 on the lite/file backend which deletes on complete; backends that
|
|
358
|
+
# track completion expose completed_count).
|
|
359
|
+
# "failed", "dead", "dead_letter" are ALIASES that all count the
|
|
360
|
+
# dead-letter store — jobs whose attempts >= max_retries and that have
|
|
361
|
+
# given up. Use dead_letters() to list them. Retryable-but-attempted
|
|
362
|
+
# jobs are NOT counted by size("failed"); use failed() to list them or
|
|
363
|
+
# size("pending") to include them in a total.
|
|
334
364
|
def size(status: "pending")
|
|
335
365
|
case status.to_s
|
|
336
366
|
when "pending"
|
|
@@ -164,9 +164,26 @@ module Tina4
|
|
|
164
164
|
|
|
165
165
|
# Explicit re-queue requested by the caller (job.retry()). Always
|
|
166
166
|
# re-enqueues regardless of the retry limit — a manual override, distinct
|
|
167
|
-
# from the automatic fail() path.
|
|
167
|
+
# from the automatic fail() path. Cleans up BOTH the reservation record
|
|
168
|
+
# and any dead-letter file for this id, so a caller who iterates
|
|
169
|
+
# dead_letters and calls job.retry on each doesn't leave the dead-letter
|
|
170
|
+
# directory carrying duplicates (3.13.105, parity port of PY-12-05).
|
|
171
|
+
# Aligns with retry_job(id) which had always unlinked the dead-letter
|
|
172
|
+
# file — two spellings of the same intent that previously diverged.
|
|
173
|
+
# Increments attempts, clears the error.
|
|
168
174
|
def retry(job, delay_seconds: 0)
|
|
169
175
|
clear_reservation(job.topic, job.id)
|
|
176
|
+
# Drop any dead-letter file for this id BEFORE the re-queue: if this
|
|
177
|
+
# job came from dead_letters it lives in @dead_letter_dir and would
|
|
178
|
+
# otherwise stay on disk while a fresh pending file appears in
|
|
179
|
+
# topic_path, so the next dead_letters call reports the job again
|
|
180
|
+
# and a consumer processes it twice.
|
|
181
|
+
begin
|
|
182
|
+
File.unlink(job_file(@dead_letter_dir, job.id))
|
|
183
|
+
rescue Errno::ENOENT
|
|
184
|
+
# No dead-letter file for this id (manual .retry on a live job) —
|
|
185
|
+
# nothing to clean up.
|
|
186
|
+
end
|
|
170
187
|
job.attempts += 1
|
|
171
188
|
requeue_job(job, delay_seconds: delay_seconds, error: nil)
|
|
172
189
|
end
|
|
@@ -291,8 +291,19 @@ module Tina4
|
|
|
291
291
|
.map { |doc| job_from_doc(doc) }
|
|
292
292
|
end
|
|
293
293
|
|
|
294
|
+
# Delete every doc in this queue whose status matches.
|
|
295
|
+
#
|
|
296
|
+
# 3.13.105 (parity port): route the dead-letter statuses ("dead",
|
|
297
|
+
# "failed", "dead_letter") to the ".dead_letter" topic namespace where
|
|
298
|
+
# they actually live — pre-fix, purge("dead") delete_many'd on
|
|
299
|
+
# {topic: topic, status: "dead"}, matching zero docs and silently
|
|
300
|
+
# returning 0 while the dead letters sat untouched in the sibling
|
|
301
|
+
# namespace. Every path scopes by status so a purge never nukes docs it
|
|
302
|
+
# was not asked to remove, and every path returns deleted_count so a
|
|
303
|
+
# caller can log/assert what was purged.
|
|
294
304
|
def purge(topic, status)
|
|
295
|
-
|
|
305
|
+
namespace = dead_status?(status) ? "#{topic}.dead_letter" : topic
|
|
306
|
+
result = collection.delete_many(topic: namespace, status: status.to_s)
|
|
296
307
|
result.deleted_count
|
|
297
308
|
end
|
|
298
309
|
|
|
@@ -326,20 +337,31 @@ module Tina4
|
|
|
326
337
|
result.modified_count
|
|
327
338
|
end
|
|
328
339
|
|
|
329
|
-
# Move
|
|
330
|
-
#
|
|
340
|
+
# Move dead-lettered jobs back to their main topic as pending.
|
|
341
|
+
#
|
|
342
|
+
# With +job_id+, revives that ONE dead letter and returns true when
|
|
343
|
+
# found (false otherwise). With no id, iterates every dead letter for
|
|
344
|
+
# the topic and revives ALL — materialised into an array before
|
|
345
|
+
# reducing, so a truthy first result never short-circuits away the
|
|
346
|
+
# rest (the class of bug the parity port of PY-12-04 pins). Ruby's
|
|
347
|
+
# dead-letter model stores the SAME document with topic flipped to
|
|
348
|
+
# "<topic>.dead_letter", so reviving is the reverse in-place update:
|
|
349
|
+
# flip the topic back, reset status/available_at/error. Mirrors
|
|
350
|
+
# LiteBackend#retry_job.
|
|
331
351
|
def retry_job(topic, job_id: nil, delay_seconds: 0)
|
|
332
352
|
filter = { topic: "#{topic}.dead_letter", status: "dead" }
|
|
333
353
|
filter[:_id] = job_id if job_id
|
|
334
|
-
|
|
335
|
-
return false
|
|
354
|
+
docs = collection.find(filter).to_a
|
|
355
|
+
return false if docs.empty?
|
|
336
356
|
|
|
337
357
|
available = delay_seconds.to_f > 0 ? (Time.now.utc + delay_seconds.to_f) : Time.now.utc
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
358
|
+
docs.each do |doc|
|
|
359
|
+
collection.update_one(
|
|
360
|
+
{ _id: doc["_id"] },
|
|
361
|
+
{ "$set" => { topic: topic, status: "pending", error: nil,
|
|
362
|
+
reserved_at: nil, available_at: available } }
|
|
363
|
+
)
|
|
364
|
+
end
|
|
343
365
|
true
|
|
344
366
|
end
|
|
345
367
|
|
|
@@ -358,6 +380,12 @@ module Tina4
|
|
|
358
380
|
|
|
359
381
|
private
|
|
360
382
|
|
|
383
|
+
DEAD_STATES = %w[failed dead dead_letter].freeze
|
|
384
|
+
|
|
385
|
+
def dead_status?(status)
|
|
386
|
+
DEAD_STATES.include?(status.to_s)
|
|
387
|
+
end
|
|
388
|
+
|
|
361
389
|
# A stored document as a plain Hash with string keys, CARRYING attempts
|
|
362
390
|
# and error.
|
|
363
391
|
#
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -1174,6 +1174,12 @@ module Tina4
|
|
|
1174
1174
|
# the auth gate into a 500. It degrades to an empty session, which means
|
|
1175
1175
|
# no token, which means the ordinary 401 below - a SERVED request.
|
|
1176
1176
|
session = Tina4::Session.new(env, degrade_on_backend_failure: true)
|
|
1177
|
+
sso = session.get("_tina4_sso")
|
|
1178
|
+
identity = sso.is_a?(Hash) ? sso["identity"] : nil
|
|
1179
|
+
if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
|
|
1180
|
+
env["tina4.auth_payload"] = identity
|
|
1181
|
+
return nil
|
|
1182
|
+
end
|
|
1177
1183
|
session_token = session.get("token")
|
|
1178
1184
|
if session_token && !session_token.empty?
|
|
1179
1185
|
token = session_token
|
data/lib/tina4/session.rb
CHANGED
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -16,7 +16,70 @@ module Tina4
|
|
|
16
16
|
# # => "SELECT * FROM users ROWS 6 TO 15"
|
|
17
17
|
#
|
|
18
18
|
class SQLTranslator
|
|
19
|
+
SPATIAL_ENGINES = %w[postgres postgresql].freeze
|
|
20
|
+
SPATIAL_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/
|
|
21
|
+
|
|
19
22
|
class << self
|
|
23
|
+
def require_spatial(engine, feature)
|
|
24
|
+
name = engine.to_s.downcase
|
|
25
|
+
return name if SPATIAL_ENGINES.include?(name)
|
|
26
|
+
raise SpatialNotSupportedError,
|
|
27
|
+
"#{feature} is not supported on the '#{name.empty? ? 'unknown' : name}' database engine. " \
|
|
28
|
+
"Tina4 GIS support is PostGIS-first: use PostgreSQL with CREATE EXTENSION postgis. " \
|
|
29
|
+
"Tina4 will not replace a spatial query with an approximate coordinate query."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def spatial_identifier(name, what = "column")
|
|
33
|
+
text = name.to_s
|
|
34
|
+
raise ArgumentError, "Spatial #{what} is not a valid SQL identifier: #{text}" unless SPATIAL_IDENTIFIER.match?(text)
|
|
35
|
+
text
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def point_column_type(engine, srid = Point::DEFAULT_SRID)
|
|
39
|
+
require_spatial(engine, "PointField")
|
|
40
|
+
"geography(Point,#{Integer(srid)})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def spatial_index(engine, table, column)
|
|
44
|
+
require_spatial(engine, "spatial index creation")
|
|
45
|
+
table = spatial_identifier(table, "table")
|
|
46
|
+
column = spatial_identifier(column)
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS #{table.tr('.', '_')}_#{column}_gist ON #{table} USING GIST (#{column})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def point_literal(engine, srid = Point::DEFAULT_SRID)
|
|
51
|
+
require_spatial(engine, "spatial predicates")
|
|
52
|
+
"ST_SetSRID(ST_MakePoint(?, ?), #{Integer(srid)})::geography"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def within_distance(engine, column, srid = Point::DEFAULT_SRID)
|
|
56
|
+
"ST_DWithin(#{spatial_identifier(column)}, #{point_literal(engine, srid)}, ?)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def distance(engine, column, srid = Point::DEFAULT_SRID)
|
|
60
|
+
"ST_Distance(#{spatial_identifier(column)}, #{point_literal(engine, srid)})"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def distance_as(engine, column, alias_name, srid = Point::DEFAULT_SRID)
|
|
64
|
+
"#{distance(engine, column, srid)} AS #{spatial_identifier(alias_name, 'result alias')}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def geometry_literal(engine, form = :ewkt, srid = Point::DEFAULT_SRID)
|
|
68
|
+
require_spatial(engine, "spatial predicates")
|
|
69
|
+
return "ST_GeogFromText(?)" if form.to_sym == :ewkt
|
|
70
|
+
return "ST_SetSRID(ST_GeomFromGeoJSON(?), #{Integer(srid)})::geography" if form.to_sym == :geojson
|
|
71
|
+
raise ArgumentError, "Unsupported spatial geometry form: #{form}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def intersects(engine, column, form = :ewkt, srid = Point::DEFAULT_SRID)
|
|
75
|
+
"ST_Intersects(#{spatial_identifier(column)}, #{geometry_literal(engine, form, srid)})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def bbox(engine, column, srid = Point::DEFAULT_SRID)
|
|
79
|
+
require_spatial(engine, "bbox")
|
|
80
|
+
"ST_Intersects(#{spatial_identifier(column)}, ST_MakeEnvelope(?, ?, ?, ?, #{Integer(srid)})::geography)"
|
|
81
|
+
end
|
|
82
|
+
|
|
20
83
|
# ── Literal-safe rewriting ────────────────────────────────────
|
|
21
84
|
#
|
|
22
85
|
# A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
|
data/lib/tina4/sso.rb
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module Tina4
|
|
12
|
+
class SsoError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Provider-neutral, configuration-first OpenID Connect SSO.
|
|
15
|
+
class Sso
|
|
16
|
+
PENDING_KEY = "_tina4_sso_pending"
|
|
17
|
+
SESSION_KEY = "_tina4_sso"
|
|
18
|
+
attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
|
|
19
|
+
:verify, :post_logout_redirect_uri, :claim_map
|
|
20
|
+
@mounted = false
|
|
21
|
+
|
|
22
|
+
def initialize(options = {})
|
|
23
|
+
@issuer = (options[:issuer] || ENV["TINA4_SSO_ISSUER"] || "").sub(%r{/$}, "")
|
|
24
|
+
@client_id = options[:client_id] || ENV["TINA4_SSO_CLIENT_ID"] || ""
|
|
25
|
+
@client_secret = options.key?(:client_secret) ? options[:client_secret] : ENV["TINA4_SSO_CLIENT_SECRET"]
|
|
26
|
+
@redirect_uri = options[:redirect_uri] || ENV["TINA4_SSO_REDIRECT_URI"] || ""
|
|
27
|
+
@scopes = options[:scopes] || json_env("TINA4_SSO_SCOPES", %w[openid profile email])
|
|
28
|
+
@verify = (options[:verify] || ENV["TINA4_SSO_VERIFY"] || "introspection").downcase
|
|
29
|
+
@post_logout_redirect_uri = options[:post_logout_redirect_uri] || ENV["TINA4_SSO_POST_LOGOUT_REDIRECT_URI"]
|
|
30
|
+
@claim_map = options[:claim_map] || json_env("TINA4_SSO_CLAIM_MAP", {})
|
|
31
|
+
@metadata = {}
|
|
32
|
+
validate_config!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.from_issuer(options = {})
|
|
36
|
+
new(options).tap(&:discover)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.configured?
|
|
40
|
+
%w[TINA4_SSO_ISSUER TINA4_SSO_CLIENT_ID TINA4_SSO_REDIRECT_URI].all? { |key| !ENV[key].to_s.empty? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def json_env(name, fallback)
|
|
44
|
+
raw = ENV[name]
|
|
45
|
+
raw.nil? || raw.empty? ? fallback : JSON.parse(raw)
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise SsoError, "#{name} must be valid JSON", cause: e
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.secure_url!(value, name)
|
|
51
|
+
uri = URI.parse(value)
|
|
52
|
+
raise SsoError, "#{name} must be an absolute URL" unless uri.absolute? && uri.host
|
|
53
|
+
|
|
54
|
+
loopback = %w[localhost 127.0.0.1 ::1].include?(uri.host)
|
|
55
|
+
raise SsoError, "#{name} must use HTTPS except on loopback" unless uri.scheme == "https" || (uri.scheme == "http" && loopback)
|
|
56
|
+
rescue URI::InvalidURIError => e
|
|
57
|
+
raise SsoError, "#{name} must be an absolute URL", cause: e
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_config!
|
|
61
|
+
if @issuer.empty? || @client_id.empty? || @redirect_uri.empty?
|
|
62
|
+
raise SsoError, "TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required"
|
|
63
|
+
end
|
|
64
|
+
self.class.secure_url!(@issuer, "issuer")
|
|
65
|
+
self.class.secure_url!(@redirect_uri, "redirect URI")
|
|
66
|
+
raise SsoError, "TINA4_SSO_VERIFY must be introspection or jwks" unless %w[introspection jwks].include?(@verify)
|
|
67
|
+
raise SsoError, "jwks verification requires an installed cryptography capability" if @verify == "jwks"
|
|
68
|
+
raise SsoError, "introspection verification requires TINA4_SSO_CLIENT_SECRET" if @verify == "introspection" && @client_secret.to_s.empty?
|
|
69
|
+
raise SsoError, "TINA4_SSO_SCOPES must be a list containing openid" unless @scopes.is_a?(Array) && @scopes.include?("openid")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def request_json(url, form: nil, bearer: nil, basic: false)
|
|
73
|
+
uri = URI.parse(url)
|
|
74
|
+
request = form ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
|
|
75
|
+
request["Accept"] = "application/json"
|
|
76
|
+
request.set_form_data(form.transform_values(&:to_s)) if form
|
|
77
|
+
request["Authorization"] = "Bearer #{bearer}" if bearer
|
|
78
|
+
request.basic_auth(@client_id, @client_secret) if basic
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = uri.scheme == "https"
|
|
81
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
82
|
+
http.open_timeout = 10
|
|
83
|
+
http.read_timeout = 10
|
|
84
|
+
response = http.request(request)
|
|
85
|
+
raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
|
|
86
|
+
result = JSON.parse(response.body)
|
|
87
|
+
raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
|
|
88
|
+
result
|
|
89
|
+
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
|
|
90
|
+
raise SsoError, "OIDC provider request failed", cause: e
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def discover(force = false)
|
|
94
|
+
return @metadata.dup unless @metadata.empty? || force
|
|
95
|
+
result = request_json("#{@issuer}/.well-known/openid-configuration")
|
|
96
|
+
raise SsoError, "OIDC discovery issuer does not exactly match configuration" unless result["issuer"] == @issuer
|
|
97
|
+
required = %w[authorization_endpoint token_endpoint]
|
|
98
|
+
required << "introspection_endpoint" if @verify == "introspection"
|
|
99
|
+
required.each do |key|
|
|
100
|
+
raise SsoError, "OIDC discovery is missing #{key}" if result[key].to_s.empty?
|
|
101
|
+
self.class.secure_url!(result[key], key)
|
|
102
|
+
end
|
|
103
|
+
@metadata = result
|
|
104
|
+
result.dup
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.safe_return(value)
|
|
108
|
+
return "/" if value.to_s.empty? || !value.start_with?("/") || value.start_with?("//") || value.include?("\\")
|
|
109
|
+
value.each_byte.any? { |byte| byte < 32 } ? "/" : value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def session(value)
|
|
113
|
+
value.is_a?(Tina4::Session) ? value : value&.session
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def login(request_or_session, return_to = "/")
|
|
117
|
+
current = session(request_or_session)
|
|
118
|
+
raise SsoError, "SSO login requires a Tina4 Session" unless current
|
|
119
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
120
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
121
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
122
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
123
|
+
current.set(PENDING_KEY, {
|
|
124
|
+
"state" => state, "nonce" => nonce, "verifier" => verifier,
|
|
125
|
+
"return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
|
|
126
|
+
})
|
|
127
|
+
query = URI.encode_www_form(
|
|
128
|
+
client_id: @client_id, redirect_uri: @redirect_uri, response_type: "code",
|
|
129
|
+
scope: @scopes.join(" "), state: state, nonce: nonce,
|
|
130
|
+
code_challenge: challenge, code_challenge_method: "S256"
|
|
131
|
+
)
|
|
132
|
+
"#{discover['authorization_endpoint']}?#{query}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.secure_equal(left, right)
|
|
136
|
+
a = left.to_s
|
|
137
|
+
b = right.to_s
|
|
138
|
+
a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.jwt_payload(token)
|
|
142
|
+
part = token.split(".")[1].to_s
|
|
143
|
+
JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
|
|
144
|
+
rescue JSON::ParserError, ArgumentError
|
|
145
|
+
raise SsoError, "provider returned an invalid ID token"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def introspect(access_token)
|
|
149
|
+
result = request_json(discover["introspection_endpoint"],
|
|
150
|
+
form: { token: access_token, token_type_hint: "access_token" }, basic: true)
|
|
151
|
+
unless result["active"] == true && result["iss"] == @issuer
|
|
152
|
+
raise SsoError, "OIDC access token is inactive or has the wrong issuer"
|
|
153
|
+
end
|
|
154
|
+
audience = result["aud"] || result["client_id"]
|
|
155
|
+
valid = audience.is_a?(Array) ? audience.include?(@client_id) : audience == @client_id
|
|
156
|
+
valid ||= result["client_id"] == @client_id
|
|
157
|
+
raise SsoError, "OIDC token audience mismatch" unless valid
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def claim(claims, configured, fallback)
|
|
162
|
+
(configured || fallback).split(".").reduce(claims) { |value, key| value.is_a?(Hash) ? value[key] : nil }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def normalize(claims)
|
|
166
|
+
subject = claim(claims, @claim_map["subject"], "sub")
|
|
167
|
+
identity_issuer = claim(claims, @claim_map["issuer"], "iss") || @issuer
|
|
168
|
+
raise SsoError, "OIDC identity is missing a valid issuer or subject" if subject.to_s.empty? || identity_issuer != @issuer
|
|
169
|
+
roles = Array(claim(claims, @claim_map["roles"], "realm_access.roles"))
|
|
170
|
+
roles += Array(claims.dig("resource_access", @client_id, "roles"))
|
|
171
|
+
groups = Array(claim(claims, @claim_map["groups"], "groups"))
|
|
172
|
+
{
|
|
173
|
+
"issuer" => identity_issuer, "subject" => subject,
|
|
174
|
+
"username" => claim(claims, @claim_map["username"], "preferred_username"),
|
|
175
|
+
"email" => claim(claims, @claim_map["email"], "email"),
|
|
176
|
+
"name" => claim(claims, @claim_map["name"], "name"),
|
|
177
|
+
"roles" => roles.map(&:to_s).uniq.sort, "groups" => groups.map(&:to_s).uniq.sort
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def callback(request_or_session, query = nil)
|
|
182
|
+
current = session(request_or_session)
|
|
183
|
+
values = query || request_or_session.params
|
|
184
|
+
pending = current&.get(PENDING_KEY)
|
|
185
|
+
current&.delete(PENDING_KEY)
|
|
186
|
+
unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
|
|
187
|
+
raise SsoError, "OIDC callback state is invalid or already consumed"
|
|
188
|
+
end
|
|
189
|
+
raise SsoError, "OIDC callback state has expired" if Time.now.to_i - pending.fetch("created_at", 0).to_i > 600
|
|
190
|
+
metadata = discover
|
|
191
|
+
tokens = request_json(metadata["token_endpoint"], form: {
|
|
192
|
+
grant_type: "authorization_code", code: values["code"], redirect_uri: @redirect_uri,
|
|
193
|
+
client_id: @client_id, code_verifier: pending["verifier"]
|
|
194
|
+
}, basic: !@client_secret.to_s.empty?)
|
|
195
|
+
raise SsoError, "OIDC token response is incomplete" if tokens["access_token"].to_s.empty? || tokens["id_token"].to_s.empty?
|
|
196
|
+
raise SsoError, "JWKS verification requires an installed cryptography capability" if @verify == "jwks"
|
|
197
|
+
claims = introspect(tokens["access_token"])
|
|
198
|
+
unless self.class.secure_equal(self.class.jwt_payload(tokens["id_token"])["nonce"], pending["nonce"])
|
|
199
|
+
raise SsoError, "OIDC ID token nonce mismatch"
|
|
200
|
+
end
|
|
201
|
+
claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
|
|
202
|
+
identity = normalize(claims)
|
|
203
|
+
current.regenerate
|
|
204
|
+
current.set(SESSION_KEY, {
|
|
205
|
+
"version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
|
|
206
|
+
"refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
|
|
207
|
+
"expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
|
|
208
|
+
})
|
|
209
|
+
{ "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def identity(request_or_session)
|
|
213
|
+
stored = session(request_or_session)&.get(SESSION_KEY)
|
|
214
|
+
value = stored.is_a?(Hash) ? stored["identity"] : nil
|
|
215
|
+
request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def refresh(request_or_session)
|
|
220
|
+
current = session(request_or_session)
|
|
221
|
+
stored = current&.get(SESSION_KEY)
|
|
222
|
+
unless stored.is_a?(Hash) && !stored["refresh_token"].to_s.empty?
|
|
223
|
+
current&.delete(SESSION_KEY)
|
|
224
|
+
raise SsoError, "OIDC session cannot be refreshed"
|
|
225
|
+
end
|
|
226
|
+
metadata = discover
|
|
227
|
+
tokens = request_json(metadata["token_endpoint"], form: {
|
|
228
|
+
grant_type: "refresh_token", refresh_token: stored["refresh_token"], client_id: @client_id
|
|
229
|
+
}, basic: !@client_secret.to_s.empty?)
|
|
230
|
+
claims = introspect(tokens["access_token"])
|
|
231
|
+
claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
|
|
232
|
+
value = normalize(claims)
|
|
233
|
+
current.set(SESSION_KEY, stored.merge(
|
|
234
|
+
"identity" => value, "access_token" => tokens["access_token"],
|
|
235
|
+
"refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
|
|
236
|
+
"id_token" => tokens["id_token"] || stored["id_token"],
|
|
237
|
+
"expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
|
|
238
|
+
))
|
|
239
|
+
value
|
|
240
|
+
rescue StandardError
|
|
241
|
+
current&.delete(SESSION_KEY)
|
|
242
|
+
raise
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def logout(request_or_session, return_to = "/")
|
|
246
|
+
current = session(request_or_session)
|
|
247
|
+
stored = current&.get(SESSION_KEY)
|
|
248
|
+
current&.destroy
|
|
249
|
+
endpoint = discover["end_session_endpoint"]
|
|
250
|
+
target = @post_logout_redirect_uri || self.class.safe_return(return_to)
|
|
251
|
+
return target unless endpoint
|
|
252
|
+
params = { post_logout_redirect_uri: target, client_id: @client_id }
|
|
253
|
+
params[:id_token_hint] = stored["id_token"] if stored.is_a?(Hash) && stored["id_token"]
|
|
254
|
+
"#{endpoint}?#{URI.encode_www_form(params)}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def self.mount_configured
|
|
258
|
+
return false if @mounted || !configured?
|
|
259
|
+
owned = [["GET", "/auth/login"], ["GET", "/auth/callback"], ["POST", "/auth/logout"]]
|
|
260
|
+
collisions = Tina4::Router.routes.select { |route| owned.include?([route.method, route.path]) }
|
|
261
|
+
unless collisions.empty?
|
|
262
|
+
raise SsoError, "SSO route collision: #{collisions.map { |route| "#{route.method} #{route.path}" }.join(', ')}"
|
|
263
|
+
end
|
|
264
|
+
sso = from_issuer
|
|
265
|
+
Tina4::Router.get("/auth/login") do |request, response|
|
|
266
|
+
response.redirect(sso.login(request, request.params["return_to"] || "/"))
|
|
267
|
+
end
|
|
268
|
+
Tina4::Router.get("/auth/callback") do |request, response|
|
|
269
|
+
begin
|
|
270
|
+
response.redirect(sso.callback(request)["return_to"])
|
|
271
|
+
rescue SsoError => e
|
|
272
|
+
response.json({ error: "SSO_CALLBACK_FAILED", message: e.message }, 400)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
Tina4::Router.post("/auth/logout") do |request, response|
|
|
276
|
+
response.redirect(sso.logout(request, request.params["return_to"] || "/"))
|
|
277
|
+
end
|
|
278
|
+
@mounted = true
|
|
279
|
+
true
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
SSO = Sso
|
|
284
|
+
end
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -161,6 +161,17 @@ module Tina4
|
|
|
161
161
|
}
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
sso_issuer = ENV["TINA4_SSO_ISSUER"].to_s.sub(%r{/$}, "")
|
|
165
|
+
unless sso_issuer.empty?
|
|
166
|
+
schemes["oidc"] = {
|
|
167
|
+
"type" => "openIdConnect",
|
|
168
|
+
"openIdConnectUrl" => "#{sso_issuer}/.well-known/openid-configuration"
|
|
169
|
+
}
|
|
170
|
+
schemes["ssoSession"] = {
|
|
171
|
+
"type" => "apiKey", "in" => "cookie", "name" => "tina4_session"
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
164
175
|
# Registered schemes win (let an app override bearerAuth or add oauth2).
|
|
165
176
|
@registered_schemes.each { |name, defn| schemes[name] = defn }
|
|
166
177
|
schemes
|
|
@@ -302,7 +313,11 @@ module Tina4
|
|
|
302
313
|
#
|
|
303
314
|
# Both flags are honoured. A custom auth_handler protects a route even
|
|
304
315
|
# when auth_required is false for its method, so it is still documented.
|
|
305
|
-
|
|
316
|
+
if route.auth_required || route.auth_handler
|
|
317
|
+
requirements = [{ default_scheme => [] }]
|
|
318
|
+
requirements << { "ssoSession" => [] } if default_scheme == "bearerAuth" && schemes.key?("ssoSession")
|
|
319
|
+
return sanitize_security(requirements, schemes)
|
|
320
|
+
end
|
|
306
321
|
|
|
307
322
|
nil
|
|
308
323
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "tina4/database_adapter"
|
|
|
15
15
|
require_relative "tina4/database_url"
|
|
16
16
|
require_relative "tina4/database"
|
|
17
17
|
require_relative "tina4/database_result"
|
|
18
|
+
require_relative "tina4/point"
|
|
18
19
|
require_relative "tina4/field_types"
|
|
19
20
|
require_relative "tina4/orm"
|
|
20
21
|
require_relative "tina4/query_builder"
|
|
@@ -25,6 +26,7 @@ require_relative "tina4/template"
|
|
|
25
26
|
require_relative "tina4/frond"
|
|
26
27
|
require_relative "tina4/auth"
|
|
27
28
|
require_relative "tina4/session"
|
|
29
|
+
require_relative "tina4/sso"
|
|
28
30
|
require_relative "tina4/middleware"
|
|
29
31
|
require_relative "tina4/cors"
|
|
30
32
|
require_relative "tina4/rate_limiter"
|
|
@@ -487,6 +489,10 @@ module Tina4
|
|
|
487
489
|
# Auto-discover routes
|
|
488
490
|
auto_discover(root_dir)
|
|
489
491
|
|
|
492
|
+
# Configuration-first OIDC mounts after application discovery so a
|
|
493
|
+
# canonical-path collision fails loudly rather than being shadowed.
|
|
494
|
+
Tina4::Sso.mount_configured
|
|
495
|
+
|
|
490
496
|
# Apply pending DB migrations on startup (non-breaking — see method doc).
|
|
491
497
|
# Runs AFTER route discovery / DB bind, BEFORE serving.
|
|
492
498
|
auto_migrate_on_startup!(root_dir)
|
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.
|
|
4
|
+
version: 3.13.105
|
|
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-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -397,6 +397,7 @@ files:
|
|
|
397
397
|
- lib/tina4/mqtt_message.rb
|
|
398
398
|
- lib/tina4/orm.rb
|
|
399
399
|
- lib/tina4/plan.rb
|
|
400
|
+
- lib/tina4/point.rb
|
|
400
401
|
- lib/tina4/port_takeover.rb
|
|
401
402
|
- lib/tina4/project_index.rb
|
|
402
403
|
- lib/tina4/public/__feedback/widget.js
|
|
@@ -448,6 +449,7 @@ files:
|
|
|
448
449
|
- lib/tina4/session_handlers/valkey_handler.rb
|
|
449
450
|
- lib/tina4/shutdown.rb
|
|
450
451
|
- lib/tina4/sql_translator.rb
|
|
452
|
+
- lib/tina4/sso.rb
|
|
451
453
|
- lib/tina4/swagger.rb
|
|
452
454
|
- lib/tina4/template.rb
|
|
453
455
|
- lib/tina4/templates/base.twig
|