axhub-sdk 0.5.0 → 0.6.0

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.
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AxHub
4
- module Data
5
- # Typed data-layer errors. These subclass the existing single AxHub::Error so
6
- # the (category, code) contract that the conformance vectors + error tests
7
- # match on keeps working, while callers can still rescue the specific failure
8
- # (mirrors node/python LegacyCursorError / InvalidCursorError / ValidationError
9
- # / TableNotFoundError / IntrospectFailedError / ScanLimitExceededError).
10
- class ValidationError < AxHub::Error
11
- def initialize(message, code = 'validation', status: 0, retryable: false, request_id: nil)
12
- super(category: 'validation', code: code, message: message, status: status, retryable: retryable, request_id: request_id)
13
- end
14
- end
15
-
16
- # Raised when an after/before keyset or v1:/v2: cursor token is supplied; the
17
- # live AX Hub data API is offset-only (mirrors node LegacyCursorError).
18
- class LegacyCursorError < AxHub::Error
19
- def initialize(message, request_id: nil)
20
- super(category: 'validation', code: 'legacy_cursor', message: message, status: 0, retryable: false, request_id: request_id)
21
- end
22
- end
23
-
24
- class InvalidCursorError < AxHub::Error
25
- def initialize(message, request_id: nil)
26
- super(category: 'validation', code: 'invalid_cursor', message: message, status: 0, retryable: false, request_id: request_id)
27
- end
28
- end
29
-
30
- class TableNotFoundError < AxHub::Error
31
- def initialize(message, request_id: nil)
32
- super(category: 'not_found', code: 'table_not_found', message: message, status: 404, retryable: false, request_id: request_id)
33
- end
34
- end
35
-
36
- class IntrospectFailedError < AxHub::Error
37
- def initialize(message, status: 0, retryable: false, request_id: nil)
38
- super(category: 'internal', code: 'introspect_failed', message: message, status: status, retryable: retryable, request_id: request_id)
39
- end
40
- end
41
-
42
- class ScanLimitExceededError < AxHub::Error
43
- def initialize(message, request_id: nil)
44
- super(category: 'internal', code: 'scan_limit_exceeded', message: message, status: 0, retryable: false, request_id: request_id)
45
- end
46
- end
47
- # The backend 400s an unfiltered list/count on NON-owner-scoped tables
48
- # ("최소 1개의 WHERE 필터가 필요해요") but ACCEPTS it on owner-scoped tables
49
- # (rows auto-scope to the caller) — both confirmed live 2026-06. A client
50
- # pre-check cannot tell them apart (0.3.0 regression), so the request goes
51
- # through and only the backend 400 is normalized.
52
- def self.map_where_required(op)
53
- yield
54
- rescue StandardError => e
55
- code = e.respond_to?(:code) ? e.code : nil
56
- status = e.respond_to?(:status) ? e.status : nil
57
- if code.to_s == 'required' && status.to_i == 400
58
- raise ValidationError.new(
59
- "AxHub data #{op} requires at least one WHERE filter on this table " \
60
- '(the backend rejects unfiltered scans on non-owner-scoped tables). Pass `where:`.',
61
- 'where_required'
62
- )
63
- end
64
- raise
65
- end
66
-
67
- end
68
- end
@@ -1,100 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AxHub
4
- module Data
5
- # Offset pagination helpers (subset of node core pagination that the data
6
- # ergonomic layer depends on).
7
- #
8
- # Ported: serialize_order_by / normalize_order_by, is_v2_cursor,
9
- # MAX_CURSOR_TOKEN_LENGTH, list_all, and the PaginatedList / ListAllItem
10
- # result shapes. Keyset encode/decode is intentionally NOT ported: the live
11
- # AX Hub data API is offset-only, so the data layer only needs the order-by
12
- # normalizer and the cursor-shape guards used to reject legacy keyset tokens.
13
- MAX_CURSOR_TOKEN_LENGTH = 4096
14
-
15
- PaginatedList = Struct.new(
16
- :items, :next_cursor, :first_cursor, :has_next, :has_prev, :total, :total_is_exact,
17
- keyword_init: true
18
- )
19
-
20
- # Either an item (type == :item) or a drift marker (type == :drift) when the
21
- # backend total grows mid-scan.
22
- ListAllItem = Struct.new(:type, :value, :added_since, keyword_init: true) do
23
- def initialize(type:, value: nil, added_since: 0)
24
- super(type: type, value: value, added_since: added_since)
25
- end
26
- end
27
-
28
- module_function
29
-
30
- # order_by = String | Array<{ field: String, dir?: "asc"|"desc" }>
31
- def normalize_order_by(order_by)
32
- if order_by.is_a?(String)
33
- fields = []
34
- order_by.split(',').each do |part|
35
- trimmed = part.strip
36
- f = if trimmed.start_with?('-')
37
- { 'field' => trimmed[1..], 'dir' => 'desc' }
38
- elsif trimmed.start_with?('+')
39
- { 'field' => trimmed[1..], 'dir' => 'asc' }
40
- else
41
- { 'field' => trimmed, 'dir' => 'asc' }
42
- end
43
- fields << f unless f['field'].nil? || f['field'].empty?
44
- end
45
- elsif order_by && !order_by.empty?
46
- fields = order_by.map do |p|
47
- h = p.transform_keys(&:to_s)
48
- { 'field' => h['field'], 'dir' => h.fetch('dir', 'asc') }
49
- end
50
- else
51
- fields = []
52
- end
53
- if !fields.empty? && fields.none? { |f| f['field'] == 'id' }
54
- fields << { 'field' => 'id', 'dir' => 'asc' }
55
- end
56
- fields
57
- end
58
-
59
- def serialize_order_by(order_by)
60
- normalized = normalize_order_by(order_by)
61
- return (order_by.is_a?(String) ? order_by : nil) if normalized.empty?
62
-
63
- normalized.map { |f| "#{f['dir'] == 'desc' ? '-' : ''}#{f['field']}" }.join(',')
64
- end
65
-
66
- def is_v2_cursor(token)
67
- token.is_a?(String) && token.start_with?('v2:')
68
- end
69
-
70
- # Drive a paginated fetcher to exhaustion, yielding each item and a drift
71
- # marker when the backend total grows mid-iteration (mirrors node listAll).
72
- # Returns an Enumerator when no block is given (idiomatic Ruby).
73
- def list_all(fetcher, opts = {})
74
- return enum_for(:list_all, fetcher, opts) unless block_given?
75
-
76
- cursor = opts[:cursor]
77
- initial_total = nil
78
- last_total = nil
79
- loop do
80
- page = fetcher.call(page_size: opts[:page_size], cursor: cursor)
81
- unless page.total.nil?
82
- if initial_total.nil?
83
- initial_total = page.total
84
- last_total = page.total
85
- else
86
- base = last_total.nil? ? initial_total : last_total
87
- if page.total > base
88
- yield ListAllItem.new(type: :drift, added_since: page.total - base)
89
- last_total = page.total
90
- end
91
- end
92
- end
93
- page.items.each { |item| yield ListAllItem.new(type: :item, value: item) }
94
- return if page.next_cursor.nil?
95
-
96
- cursor = page.next_cursor
97
- end
98
- end
99
- end
100
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'errors'
4
-
5
- module AxHub
6
- module Data
7
- # Column projection: `select` serialization, validation, and client-side row
8
- # narrowing (mirrors node/python projection).
9
- #
10
- # serialize_select joins columns with commas into the `_select` query param.
11
- # validate_select_columns rejects an empty select and, when a schema is known,
12
- # unknown columns. project_row/project_rows narrow returned rows to the
13
- # selected keys client-side. Rows are string-keyed (camelize-off transport).
14
- module_function
15
-
16
- def serialize_select(select)
17
- return nil if select.nil?
18
-
19
- select.join(',')
20
- end
21
-
22
- def validate_select_columns(schema, select)
23
- return if select.nil?
24
-
25
- if select.length.zero?
26
- raise ValidationError.new('select must include at least one column; omit select to fetch full rows', 'select_empty')
27
- end
28
- return if schema.nil?
29
-
30
- allowed = schema.columns.keys
31
- invalid = select.reject { |c| allowed.include?(c) }
32
- return if invalid.empty?
33
-
34
- plural = invalid.length == 1 ? '' : 's'
35
- raise ValidationError.new("select contains unknown column#{plural}: #{invalid.join(', ')}", 'select_unknown_column')
36
- end
37
-
38
- def project_row(row, select)
39
- return row.dup if select.nil?
40
-
41
- select.each_with_object({}) { |k, acc| acc[k] = row[k] if row.key?(k) }
42
- end
43
-
44
- def project_rows(rows, select)
45
- return rows.map(&:dup) if select.nil?
46
-
47
- rows.map { |r| project_row(r, select) }
48
- end
49
- end
50
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'dsl/schema'
4
-
5
- module AxHub
6
- module Data
7
- # Per-client schema cache for runtime data.discover (mirrors node/python
8
- # schema-cache).
9
- #
10
- # Uses an insertion-ordered Ruby Hash for deterministic LRU eviction
11
- # (delete+reinsert = move-to-end on read/write, `shift` = evict oldest) and a
12
- # negative-TTL stale-while-error window: a transient 5xx during refresh keeps
13
- # the previous entry alive briefly instead of evicting it. The node version
14
- # de-dupes concurrent in-flight loads; the sync Ruby port omits the in-flight
15
- # map (no concurrency within a single synchronous call).
16
- DEFAULT_SCHEMA_CACHE_TTL_MS = 5 * 60_000
17
- DEFAULT_SCHEMA_CACHE_MAX_ENTRIES = 1000
18
- DEFAULT_SCHEMA_CACHE_NEGATIVE_TTL_MS = 30_000
19
-
20
- module_function
21
-
22
- def schema_cache_key(tenant_slug, app_slug, table)
23
- "#{tenant_slug}/#{app_slug}/#{table}"
24
- end
25
-
26
- class SchemaCache
27
- Entry = Struct.new(:schema, :expires_at, keyword_init: true)
28
-
29
- def initialize(max_entries: nil, ttl_ms: nil, negative_ttl_ms: nil)
30
- @store = {}
31
- @max_entries = [1, max_entries.nil? ? DEFAULT_SCHEMA_CACHE_MAX_ENTRIES : max_entries].max
32
- @ttl_ms = [1, ttl_ms.nil? ? DEFAULT_SCHEMA_CACHE_TTL_MS : ttl_ms].max
33
- @negative_ttl_ms = [0, negative_ttl_ms.nil? ? DEFAULT_SCHEMA_CACHE_NEGATIVE_TTL_MS : negative_ttl_ms].max
34
- end
35
-
36
- def size
37
- @store.size
38
- end
39
-
40
- def get(key)
41
- entry = @store[key]
42
- return nil if entry.nil?
43
-
44
- if entry.expires_at <= _now_ms
45
- @store.delete(key)
46
- return nil
47
- end
48
- # refresh recency: move to end (delete + reinsert)
49
- @store.delete(key)
50
- @store[key] = entry
51
- entry.schema
52
- end
53
-
54
- def set(key, schema, ttl_ms = nil)
55
- @store.delete(key)
56
- @store[key] = Entry.new(schema: schema, expires_at: _now_ms + [1, ttl_ms.nil? ? @ttl_ms : ttl_ms].max)
57
- _evict_overflow
58
- nil
59
- end
60
-
61
- def invalidate(key = nil)
62
- if key.nil?
63
- @store.clear
64
- else
65
- @store.delete(key)
66
- end
67
- nil
68
- end
69
-
70
- def get_or_set(key, fresh: nil, ttl_ms: nil)
71
- unless fresh
72
- cached = get(key)
73
- return cached unless cached.nil?
74
- end
75
- previous = @store[key]
76
- begin
77
- schema = yield
78
- rescue StandardError => e
79
- if !previous.nil? && @negative_ttl_ms.positive? && _transient_server_error?(e)
80
- @store.delete(key)
81
- @store[key] = Entry.new(schema: previous.schema, expires_at: _now_ms + @negative_ttl_ms)
82
- end
83
- raise
84
- end
85
- set(key, schema, ttl_ms)
86
- schema
87
- end
88
-
89
- private
90
-
91
- def _now_ms
92
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
93
- end
94
-
95
- def _transient_server_error?(err)
96
- status = err.respond_to?(:status) ? err.status : nil
97
- status.is_a?(Integer) && status >= 500
98
- end
99
-
100
- def _evict_overflow
101
- @store.shift while @store.size > @max_entries # oldest first
102
- end
103
- end
104
- end
105
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'date'
5
- require_relative 'errors'
6
-
7
- module AxHub
8
- module Data
9
- # Serialize the predicate DSL into backend filter query params (mirrors node
10
- # where-serializer + python where_serializer).
11
- #
12
- # Each pushable atom becomes column=<op>.<value> (PostgREST-style). Repeated
13
- # columns collapse into an array so the transport emits repeated query params
14
- # (URI.encode_www_form repeats array-valued keys). Only top-level and(...) of
15
- # pushable atoms and bare atoms are accepted; or/not/raw and nested-and raise
16
- # ValidationError — this matches the live backend's filter grammar.
17
- PUSHABLE_BINARY = %i[eq ne gt gte lt lte like].freeze
18
-
19
- module_function
20
-
21
- def serialize_where(expr)
22
- return {} if expr.nil?
23
-
24
- out = {}
25
- _collect_pushable_filters(expr, allow_and: true).each do |f|
26
- _append_query(out, f[:column], f[:value])
27
- end
28
- out
29
- end
30
-
31
- def _append_query(out, key, value)
32
- if !out.key?(key)
33
- out[key] = value
34
- elsif out[key].is_a?(Array)
35
- out[key] << value
36
- else
37
- out[key] = [out[key], value]
38
- end
39
- end
40
-
41
- def _collect_pushable_filters(expr, allow_and:)
42
- op = expr[:op]
43
- if PUSHABLE_BINARY.include?(op)
44
- return [{ column: expr[:column], value: "#{op}.#{_stringify(expr[:value])}" }]
45
- end
46
-
47
- if op == :in
48
- values = expr[:values].map { |v| _stringify(v) }
49
- bad = values.find { |v| v.include?(',') }
50
- unless bad.nil?
51
- raise ValidationError.new(
52
- "IN filter values cannot contain commas because the live backend uses comma-separated IN lists (bad value: #{bad})",
53
- 'filter_in_comma'
54
- )
55
- end
56
- return [{ column: expr[:column], value: "in.#{values.join(',')}" }]
57
- end
58
-
59
- if op == :and && allow_and
60
- out = []
61
- expr[:clauses].each { |clause| out.concat(_collect_pushable_filters(clause, allow_and: false)) }
62
- return out
63
- end
64
-
65
- # or / not / raw / nested-and all fall through to the rejection below.
66
- raise ValidationError.new(
67
- "Data where clause '#{op}' cannot be pushed to the live backend; use top-level and(eq/ne/gt/gte/lt/lte/in/like) only",
68
- 'unsupported_filter'
69
- )
70
- end
71
-
72
- def _stringify(value)
73
- case value
74
- when Time then value.iso8601
75
- when DateTime, Date then value.iso8601
76
- when nil then 'null'
77
- when true then 'true'
78
- when false then 'false'
79
- when String, Integer, Float then value.to_s
80
- else JSON.generate(value)
81
- end
82
- end
83
- end
84
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Ergonomic data layer for the AX Hub Ruby SDK.
4
- #
5
- # Public surface (mirrors the node `resources/data` layer):
6
- #
7
- # client.tenant(tenant_slug).app(app_slug).data.table(name_or_schema)
8
- # client.tenant(tenant_slug).app(app_slug).data.discover(table)
9
- #
10
- # returns a DataTableClient with list / list_all / count / get / insert /
11
- # insert_many / update / delete, plus the predicate DSL (where(col).eq(v) /
12
- # and_(...) / block form), define_schema(...), and offset-only pagination.
13
- require_relative 'data/errors'
14
- require_relative 'data/dsl/schema'
15
- require_relative 'data/dsl/ops'
16
- require_relative 'data/dsl/validation'
17
- require_relative 'data/pagination'
18
- require_relative 'data/projection'
19
- require_relative 'data/where_serializer'
20
- require_relative 'data/schema_cache'
21
- require_relative 'data/discover'
22
- require_relative 'data/client'
23
-
24
- module AxHub
25
- module Data
26
- # Fluent scope wrappers so the public chain reads `client.tenant(t).app(a).data`
27
- # (mirrors node/python, where `.data` on the app scope yields the ergonomic
28
- # AppDataFactory). `client.data` itself stays the operation-id OperationClient.
29
- class AppScope
30
- attr_reader :data
31
-
32
- def initialize(ergo_data, tenant_slug, app_slug)
33
- # `ergo_data` is the single per-client DataClient (memoized on Client), so
34
- # its schema cache persists across every tenant().app() chain — node parity.
35
- @data = ergo_data.scoped(tenant_slug).app(app_slug)
36
- end
37
- end
38
-
39
- class TenantScope
40
- def initialize(ergo_data, tenant_slug)
41
- @ergo_data = ergo_data
42
- @tenant_slug = tenant_slug
43
- end
44
-
45
- def app(app_slug)
46
- AppScope.new(@ergo_data, @tenant_slug, app_slug)
47
- end
48
- end
49
- end
50
-
51
- # --- Ergonomic data layer fluent surface (mirrors node client.tenant().app().data) ---
52
- # `client.data` stays the operation-id route-table OperationClient (the
53
- # conformance vectors + e2e tests depend on it). The ergonomic data layer is
54
- # reached only through the tenant/app fluent chain, exactly as in node/python.
55
- class Client
56
- # The single per-client ergonomic DataClient, lazily memoized so the schema
57
- # cache (TTL/negative-TTL/LRU) survives across tenant().app() chains (mirrors
58
- # node, where `data` is one per-SDK DataClient).
59
- def ergo_data
60
- @ergo_data ||= AxHub::Data::DataClient.new(self, schema_cache: @schema_cache_opt)
61
- end
62
-
63
- def tenant(tenant_slug)
64
- AxHub::Data::TenantScope.new(ergo_data, tenant_slug)
65
- end
66
- end
67
- end