axhub-sdk 0.2.0 → 0.3.1

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: 572be1a63c2d4f482f151942ba1b90e92c82789be69b56eee536d3b2c7ea9a3d
4
- data.tar.gz: f93cb282b2c3919ac41633345bf3a396879d0228c3b635d4be032aa4894f3b59
3
+ metadata.gz: 85d987bf30aef83087acd96efae5f26be3edb75bab6db845b2b03e20855e022f
4
+ data.tar.gz: 9a9f21590240febb36affc2af156007840ccb0d273a279a89bd520544fa5c97c
5
5
  SHA512:
6
- metadata.gz: fd0379f32050fdd452634e294d775af5291799c0370c7e4b4a696882335de68dda77560ea83a965445bffff368839ff6e3d2b87f5d3e8f45b45b0215449dd647
7
- data.tar.gz: bae059dbb674d23c7a584912367b0b644e6a5ef5fc0790e44f02b68f5ec3145027dcec11afda99436cd2c950f646ac428363797f570ae403ed92135e6da87207
6
+ metadata.gz: ff15d439f50d8b4b28f79307c951f52b4581a234c7d50c58735153d090ceb8a44885852a37ec03760254a92ade0362633780641df75b1e3e5fbaf6279984a8b2
7
+ data.tar.gz: 8b0ec9cf0a69d1b702e859ee033f1b2d35eb3df213434f8acaceb5f208206b043300e526f60134f5f1274bbfb6cc3a21592c3fa432ca431b8c25e07aa8120269
data/README.md CHANGED
@@ -1,12 +1,162 @@
1
1
  # AX Hub Ruby SDK
2
2
 
3
- AX Hub Ruby SDK for AX Hub (`https://api.axhub.ai`).
3
+ AX Hub Ruby SDK for `https://api.axhub.ai`. It gives agents a dependency-light client, generated backend route metadata, bounded-context operation clients, typed error metadata, conformance tests, and a live-testable app/data workflow.
4
4
 
5
- This SDK exposes generated AX Hub API route metadata, typed error metadata, bounded-context route facades, regression/conformance tests, and a local test app.
5
+ ## Install
6
6
 
7
- ## Verify
7
+ ```bash
8
+ gem install axhub-sdk -v 0.2.0
9
+ ```
10
+
11
+ Local development:
12
+
13
+ ```bash
14
+ bundle install
15
+ ruby -Ilib test/client_test.rb
16
+ ```
17
+
18
+ ## Required environment for agent work
19
+
20
+ ```bash
21
+ export AXHUB_TOKEN="<short-lived PAT>"
22
+ export AXHUB_TENANT_ID="cc1e58f1-8e46-4ac7-96c1-190c4cdd5b70" # test tenant
23
+ export AXHUB_TENANT_SLUG="test"
24
+ ```
25
+
26
+ PAT mode is explicit: `token_type: :pat` sends `X-Api-Key`. JWT mode is `token_type: :jwt` and sends `Authorization: Bearer`.
27
+
28
+ ## Agent quickstart: create a disposable app and table
29
+
30
+ ```ruby
31
+ require 'axhub_sdk'
32
+
33
+ client = AxHub::Client.new(
34
+ base_url: 'https://api.axhub.ai',
35
+ token: ENV.fetch('AXHUB_TOKEN'),
36
+ token_type: :pat,
37
+ default_tenant_id: ENV.fetch('AXHUB_TENANT_ID'),
38
+ default_tenant_slug: ENV.fetch('AXHUB_TENANT_SLUG', 'test')
39
+ )
40
+
41
+ me = client.request('authGetApiV1Me')
42
+ user_id = me['userId'] || (me['user'] || {})['id']
43
+ raise 'authGetApiV1Me did not return a user id' if user_id.nil? || user_id.empty?
44
+
45
+ suffix = (Time.now.to_f * 1000).to_i.to_s[-8, 8]
46
+ slug = "agent-rb-#{suffix}"
47
+ table = "items#{suffix[-6, 6]}"
48
+
49
+ app = client.apps.create(
50
+ slug: slug,
51
+ name: 'Agent Ruby README QA',
52
+ visibility: 'private',
53
+ auth_mode: 'anonymous',
54
+ resource_tier: 'S',
55
+ deploy_method: 'docker',
56
+ subdomain: slug
57
+ )
58
+ app_id = app['id']
59
+
60
+ client.request(
61
+ 'schemaPostApiV1AppsByAppIDTables',
62
+ path_params: { appID: app_id },
63
+ body: {
64
+ table_name: table,
65
+ owner_column: 'owner_id',
66
+ columns: [
67
+ { name: 'owner_id', type: 'uuid', nullable: false },
68
+ { name: 'title', type: 'text', nullable: false },
69
+ { name: 'status', type: 'text', nullable: false }
70
+ ]
71
+ }
72
+ )
73
+
74
+ row = client.request(
75
+ 'schemaPostDataByTenantSlugByAppSlugByTable',
76
+ path_params: { tenantSlug: 'test', appSlug: slug, table: table },
77
+ body: { owner_id: user_id, title: 'hello', status: 'new' }
78
+ )
79
+ puts "created #{app_id} #{table} #{row['id']}"
80
+ ```
81
+
82
+ ## How to call the full API surface
83
+
84
+ - High-level app create: `client.apps.create(**body)` uses `default_tenant_id`.
85
+ - Any route by operation id: `client.request(operation_id, path_params: {}, query: {}, body: nil)`.
86
+ - Generated facade: `client.data.schema_post_data_by_tenant_slug_by_app_slug_by_table(path_params: {}, body: {...})`.
87
+ - Route inventory: `AxHub::ROUTES`, `AxHub::CONTEXT_ROUTES`, `AxHub::ERROR_CODES`, and `AxHub::OPERATION_METHODS`.
88
+ - Errors: catch `AxHub::Error` and branch on `code`, `category`, `status`, and `retryable`.
89
+
90
+ ## Dynamic app, schema, and data operations
91
+
92
+ Use the high-level `apps.create` helper for the first app, then use generated operation IDs for every backend route. Request bodies use backend wire keys, usually `snake_case`. Responses are normalized to camelCase in this SDK family, so read `tableName`, `requestId`, `revokedAt`, and similar keys from responses.
93
+
94
+ | Task | Operation ID | Required path params | Success assertion |
95
+ |------|--------------|----------------------|-------------------|
96
+ | Create env var | `appsPostApiV1AppsByAppIDEnvVars` | `appID` | `env.list` includes `key` |
97
+ | Delete env var | `appsDeleteApiV1AppsByAppIDEnvVarsByKey` | `appID`, `key` | `env.list` no longer includes `key` |
98
+ | Create table | `schemaPostApiV1AppsByAppIDTables` | `appID` | response `tableName` equals requested name |
99
+ | Inspect table | `schemaGetApiV1AppsByAppIDTablesByTableName` | `appID`, `tableName` | response `id` and `tableName` match |
100
+ | Add column | `schemaPostApiV1AppsByAppIDTablesByTableNameColumns` | `appID`, `tableName` | inspect contains column name |
101
+ | Drop column | `schemaDeleteApiV1AppsByAppIDTablesByTableNameColumnsByColumnName` | `appID`, `tableName`, `columnName` | inspect no longer contains column name |
102
+ | Add table grant | `schemaPostApiV1AppsByAppIDTablesByTableNameGrants` | `appID`, `tableName` | response has grant `id` |
103
+ | List grants | `schemaGetApiV1AppsByAppIDTablesByTableNameGrants` | `appID`, `tableName` | list contains grant `id` |
104
+ | Revoke/delete grant | `schemaDeleteApiV1AppsByAppIDTablesByTableNameGrantsByGrantID` | `appID`, `tableName`, `grantID` | list still contains grant with `revokedAt` set |
105
+ | Insert row | `schemaPostDataByTenantSlugByAppSlugByTable` | `tenantSlug`, `appSlug`, `table` | response has row `id` and submitted fields |
106
+ | Get row | `schemaGetDataByTenantSlugByAppSlugByTableById` | `tenantSlug`, `appSlug`, `table`, `id` | response row `id` matches |
107
+ | Update row | `schemaPatchDataByTenantSlugByAppSlugByTableById` | `tenantSlug`, `appSlug`, `table`, `id` | response contains patched fields |
108
+ | List rows | `schemaGetDataByTenantSlugByAppSlugByTable` | `tenantSlug`, `appSlug`, `table` | `items` contains row `id` |
109
+ | Count rows | `schemaGetDataByTenantSlugByAppSlugByTableCount` | `tenantSlug`, `appSlug`, `table` | `count` matches expected fixture count |
110
+ | Browse admin rows | `schemaGetApiV1AppsByAppIDTablesByTableNameRows` | `appID`, `tableName` | response has `rows` and `columns` arrays |
111
+ | Delete row | `schemaDeleteDataByTenantSlugByAppSlugByTableById` | `tenantSlug`, `appSlug`, `table`, `id` | follow-up get returns `404` or `410` |
112
+ | Delete table | `schemaDeleteApiV1AppsByAppIDTablesByTableName` | `appID`, `tableName` | follow-up inspect returns `404` or `410` |
113
+ | Delete app | `appsDeleteApiV1AppsByAppID`, then `appsDeleteApiV1AppsByAppIDPermanent` | `appID` | app is soft-deleted, then permanently deleted |
114
+
115
+ Important semantics from live QA:
116
+
117
+ - Row delete is hard enough for client assertions: a follow-up row get returns `404 not_found` or `410`.
118
+ - Table delete is hard enough for client assertions: a follow-up table inspect returns `404 not_found` or `410`.
119
+ - Table grant delete is a soft revoke: the grant can remain in `listGrants`, but the same grant id must have `revokedAt` set. Do not assert disappearance.
120
+ - Deployment creation without a connected git/bootstrap source can return a precondition-style 4xx. That verifies SDK error handling, not a deploy bug.
121
+
122
+
123
+ ## Live QA evidence agents can trust
124
+
125
+ The SDK behavior documented here reflects live production QA against the AX Hub `test` tenant on 2026-06-08.
126
+
127
+ - Tenant used for destructive QA: slug `test`, id `cc1e58f1-8e46-4ac7-96c1-190c4cdd5b70`.
128
+ - Go, Java, Kotlin, Python, and Ruby each ran the generated all-operation sweep against 189 backend routes: SDK exceptions `0`, backend 5xx `0`.
129
+ - Go, Java, Kotlin, Python, and Ruby each passed strict destructive DB QA: 22 live steps, 17 assertions, 7 cleanup calls. The flow created an app, env var, table, column, table grant, row, then updated, listed, counted, browsed, deleted, and re-read to prove deletion semantics.
130
+ - Node ran the full production mutation suite and a real app bootstrap/deploy wait. Deployment id `d3a48ce3-0f9c-4bab-aa07-863c31c44460` finished `succeeded`, then the app was deleted permanently.
131
+
132
+ Do not print tokens. Use short-lived PATs for agent QA and revoke them after the run.
133
+
134
+
135
+ ## Verification commands
136
+
137
+ Use local tests for every docs/code change. Run live tests only when you intentionally want destructive QA against `test`.
138
+
139
+
140
+ ```bash
141
+ ruby -Ilib test/client_test.rb
142
+
143
+ # Destructive live all-operation sweep, only with a disposable PAT.
144
+ AXHUB_LIVE_ALL_METHODS=1 \
145
+ AXHUB_TOKEN="$AXHUB_TOKEN" \
146
+ AXHUB_LIVE_TENANT_ID="$AXHUB_TENANT_ID" \
147
+ AXHUB_LIVE_TENANT_SLUG="$AXHUB_TENANT_SLUG" \
148
+ ruby test/live_all_operations_e2e_test.rb
149
+ ```
150
+
151
+ ## Troubleshooting for agents
152
+
153
+ - `tenant_id_required`: pass `defaultTenantId` / `AXHUB_TENANT_ID` before calling `apps.create`.
154
+ - `tokenType must be explicit`: set PAT mode when using a PAT. PATs are sent as `X-Api-Key`; JWTs are sent as `Authorization: Bearer`.
155
+ - `slug_taken` or `schema_name_taken`: append a timestamp suffix and retry. Never reuse fixture names in live destructive QA.
156
+ - `permission_denied` / `not_admin`: the SDK is working. The token lacks the role for that route.
157
+ - `precondition_failed` on deploy: connect git or use the app bootstrap flow first.
158
+ - 4xx responses are expected for negative assertions. SDK bugs are unexpected exceptions, response decode failures, or backend 5xx during a valid call.
8
159
 
9
- See `.github/workflows/ci.yml` for the canonical CI commands.
10
160
 
11
161
  ## Release
12
162
 
data/axhub-sdk.gemspec CHANGED
@@ -21,4 +21,7 @@ Gem::Specification.new do |spec|
21
21
  'bug_tracker_uri' => "#{spec.homepage}/issues",
22
22
  'rubygems_mfa_required' => 'true'
23
23
  }
24
+
25
+ spec.add_development_dependency 'minitest', '~> 5.0'
26
+ spec.add_development_dependency 'rake', '~> 13.0'
24
27
  end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'dsl/schema'
5
+ require_relative 'dsl/validation'
6
+ require_relative 'errors'
7
+ require_relative 'pagination'
8
+ require_relative 'projection'
9
+ require_relative 'schema_cache'
10
+ require_relative 'where_serializer'
11
+ require_relative 'discover'
12
+
13
+ module AxHub
14
+ module Data
15
+ # Ergonomic data layer: fluent builder + dynamic-table CRUD + offset
16
+ # pagination (mirrors node index.ts DataClient / TenantDataFactory /
17
+ # AppDataFactory / DataTableClient).
18
+ #
19
+ # Wire paths (EXACTLY as node, via the raw-path transport so row bodies and
20
+ # the list envelope are returned verbatim, no snake->camel rewriting):
21
+ # list / insert GET|POST /data/{tenant}/{app}/{table}
22
+ # get / update / delete GET|PATCH|DELETE /data/{tenant}/{app}/{table}/{id}
23
+ # count GET /data/{tenant}/{app}/{table}/_count
24
+ module_function
25
+
26
+ def _encode(value)
27
+ URI.encode_www_form_component(value.to_s)
28
+ end
29
+
30
+ def _clamp_per_page(value)
31
+ return nil if value.nil?
32
+ return 100 unless value.is_a?(Numeric) && value.finite?
33
+
34
+ [100, [1, value.to_i].max].min
35
+ end
36
+
37
+ def _reject_legacy_page_options(after, before, direction, _table_name)
38
+ return if after.nil? && before.nil? && direction.nil?
39
+
40
+ raise LegacyCursorError.new(
41
+ 'after/before keyset cursors are not supported by the live AX Hub data API; use cursor/page numeric offset pagination'
42
+ )
43
+ end
44
+
45
+ def _validate_plain_cursor(cursor, _table_name)
46
+ if cursor.length > MAX_CURSOR_TOKEN_LENGTH
47
+ raise InvalidCursorError.new("Cursor token exceeds maximum size (#{MAX_CURSOR_TOKEN_LENGTH} chars)")
48
+ end
49
+ if cursor.start_with?('v1:')
50
+ raise LegacyCursorError.new(
51
+ 'Legacy v1: cursor token is not compatible with AX Hub offset-only pagination; restart pagination without cursor'
52
+ )
53
+ end
54
+ if Data.is_v2_cursor(cursor)
55
+ raise LegacyCursorError.new(
56
+ 'v2 keyset cursors are not supported by the live AX Hub data API; restart pagination and use the numeric cursor returned by list()'
57
+ )
58
+ end
59
+ unless cursor.match?(/\A-?\d+\z/)
60
+ raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token')
61
+ end
62
+ parsed = cursor.to_i
63
+ raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token') if parsed < 1
64
+ end
65
+
66
+ def _resolve_offset_page(cursor, page, table_name)
67
+ unless cursor.nil?
68
+ _validate_plain_cursor(cursor, table_name)
69
+ return cursor.to_i
70
+ end
71
+ return 1 if page.nil?
72
+ raise InvalidCursorError.new('page must be a positive integer') unless page.is_a?(Integer) && page >= 1
73
+
74
+ page
75
+ end
76
+ end
77
+
78
+ module Data
79
+ # Client bound to one {tenant}/{app}/{table} with CRUD + pagination.
80
+ class DataTableClient
81
+ attr_reader :schema
82
+
83
+ def initialize(client, tenant_slug, app_slug, table_name, schema = nil)
84
+ @client = client
85
+ @tenant_slug = tenant_slug
86
+ @app_slug = app_slug
87
+ @table_name = table_name
88
+ @schema = schema
89
+ end
90
+
91
+ def list(where: nil, order_by: nil, select: nil, page: nil, page_size: nil, limit: nil, cursor: nil, after: nil, before: nil, direction: nil)
92
+ Data.validate_select_columns(@schema, select)
93
+ Data._reject_legacy_page_options(after, before, direction, @table_name)
94
+ resolved_page = Data._resolve_offset_page(cursor, page, @table_name)
95
+ per_page = Data._clamp_per_page(page_size.nil? ? limit : page_size)
96
+ query = Data.serialize_where(where).dup
97
+ query['per_page'] = per_page unless per_page.nil?
98
+ query['page'] = resolved_page if resolved_page != 1
99
+ sort = Data.serialize_order_by(order_by)
100
+ query['sort'] = sort if sort && sort != ''
101
+ serialized_select = Data.serialize_select(select)
102
+ query['_select'] = serialized_select unless serialized_select.nil?
103
+ raw = Data.map_where_required('list') { @client.request_raw('GET', _path, query: query) } || {}
104
+ items = Data.project_rows(raw['items'] || [], select)
105
+ # mirrors node: current_page falls back to the requested page, has_next
106
+ # reads the backend `has_more` flag verbatim, has_prev derives client-side.
107
+ current_page = raw['page'].nil? ? resolved_page : raw['page']
108
+ has_next = !!(raw['has_more'] || false)
109
+ has_prev = current_page > 1
110
+ PaginatedList.new(
111
+ items: items,
112
+ next_cursor: has_next ? (current_page + 1).to_s : nil,
113
+ first_cursor: has_prev ? (current_page - 1).to_s : nil,
114
+ has_next: has_next,
115
+ has_prev: has_prev,
116
+ total_is_exact: false
117
+ )
118
+ end
119
+
120
+ def list_all(where: nil, order_by: nil, select: nil, page_size: nil, limit: nil, &block)
121
+ base = { where: where, order_by: order_by, select: select, limit: limit }
122
+ fetcher = lambda do |p|
123
+ kwargs = base.reject { |_k, v| v.nil? }
124
+ kwargs[:cursor] = p[:cursor] unless p[:cursor].nil?
125
+ ps = p[:page_size].nil? ? page_size : p[:page_size]
126
+ kwargs[:page_size] = ps unless ps.nil?
127
+ list(**kwargs)
128
+ end
129
+ Data.list_all(fetcher, { page_size: page_size }, &block)
130
+ end
131
+
132
+ def count(where: nil)
133
+ raw = Data.map_where_required('count') { @client.request_raw('GET', "#{_path}/_count", query: Data.serialize_where(where)) } || {}
134
+ raw['count']
135
+ end
136
+
137
+ def get(row_id, select: nil)
138
+ Data.validate_select_columns(@schema, select)
139
+ serialized_select = Data.serialize_select(select)
140
+ query = serialized_select.nil? ? {} : { '_select' => serialized_select }
141
+ row = @client.request_raw('GET', _path(row_id), query: query) || {}
142
+ Data.project_row(row, select)
143
+ end
144
+
145
+ def insert(row)
146
+ Data.run_schema_validation(@schema, row, 'insert')
147
+ @client.request_raw('POST', _path, body: row)
148
+ end
149
+
150
+ def insert_many(rows)
151
+ rows.each { |row| Data.run_schema_validation(@schema, row, 'insert') }
152
+ # mirrors node: no bulk endpoint exists, so insertMany loops single
153
+ # inserts and returns { items, count }.
154
+ items = rows.map { |row| insert(row) }
155
+ { 'items' => items, 'count' => items.length }
156
+ end
157
+
158
+ def update(row_id, patch)
159
+ Data.run_schema_validation(@schema, patch, 'update')
160
+ @client.request_raw('PATCH', _path(row_id), body: patch)
161
+ end
162
+
163
+ def delete(row_id)
164
+ @client.request_raw('DELETE', _path(row_id))
165
+ nil
166
+ end
167
+
168
+ private
169
+
170
+ def _path(row_id = nil)
171
+ base = "/data/#{Data._encode(@tenant_slug)}/#{Data._encode(@app_slug)}/#{Data._encode(@table_name)}"
172
+ row_id.nil? ? base : "#{base}/#{Data._encode(row_id)}"
173
+ end
174
+ end
175
+
176
+ class AppDataFactory
177
+ def initialize(data, tenant_slug, app_slug)
178
+ @data = data
179
+ @tenant_slug = tenant_slug
180
+ @app_slug = app_slug
181
+ end
182
+
183
+ def table(table)
184
+ @data.table(@tenant_slug, @app_slug, table)
185
+ end
186
+
187
+ def discover(table, fresh: nil, ttl_ms: nil)
188
+ @data.discover(@tenant_slug, @app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
189
+ end
190
+
191
+ def invalidate_schema(table = nil)
192
+ if table.nil?
193
+ @data.invalidate_schema
194
+ else
195
+ @data.invalidate_schema(@tenant_slug, @app_slug, table)
196
+ end
197
+ end
198
+ end
199
+
200
+ class TenantDataFactory
201
+ def initialize(data, tenant_slug)
202
+ @data = data
203
+ @tenant_slug = tenant_slug
204
+ end
205
+
206
+ def app(app_slug)
207
+ AppDataFactory.new(@data, @tenant_slug, app_slug)
208
+ end
209
+ end
210
+
211
+ # Entry point for the ergonomic data layer; holds the per-client schema cache
212
+ # used by discover() (mirrors node DataClient).
213
+ class DataClient
214
+ def initialize(client, schema_cache: nil)
215
+ @client = client
216
+ @schema_cache = case schema_cache
217
+ when SchemaCache then schema_cache
218
+ when Hash then SchemaCache.new(**schema_cache.transform_keys(&:to_sym))
219
+ else SchemaCache.new
220
+ end
221
+ end
222
+
223
+ def table(tenant_slug, app_slug, table)
224
+ schema = table.is_a?(DataTableSchema) ? table : nil
225
+ table_name = table.is_a?(DataTableSchema) ? table.table : table
226
+ DataTableClient.new(@client, tenant_slug, app_slug, table_name, schema)
227
+ end
228
+
229
+ def scoped(tenant_slug)
230
+ TenantDataFactory.new(self, tenant_slug)
231
+ end
232
+
233
+ def discover(tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
234
+ key = Data.schema_cache_key(tenant_slug, app_slug, table)
235
+ schema = @schema_cache.get_or_set(key, fresh: fresh, ttl_ms: ttl_ms) do
236
+ Data.fetch_discovered_schema(@client, tenant_slug, app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
237
+ end
238
+ DataTableClient.new(@client, tenant_slug, app_slug, schema.table, schema)
239
+ end
240
+
241
+ def invalidate_schema(tenant_slug = nil, app_slug = nil, table = nil)
242
+ if !tenant_slug.nil? && !app_slug.nil? && !table.nil?
243
+ @schema_cache.invalidate(Data.schema_cache_key(tenant_slug, app_slug, table))
244
+ return
245
+ end
246
+ @schema_cache.invalidate
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'dsl/schema'
5
+ require_relative 'errors'
6
+
7
+ module AxHub
8
+ module Data
9
+ # Runtime schema introspection, with appId-resolution PRIMARY and the slug
10
+ # /inspect endpoint as a best-effort fallback, plus error normalization
11
+ # (mirrors node/python discover).
12
+ #
13
+ # Primary: GET /api/v1/apps?tenant_slug=... -> GET /api/v1/apps/{appId}/tables/{table}
14
+ # Fallback: GET /api/v1/tenants/{t}/apps/{a}/tables/{table}/inspect
15
+ # Neither endpoint has a generated operation-id, so discover goes through the
16
+ # raw-path transport. camelize: true here so table_name/tableName both resolve
17
+ # (inspect payload is metadata, not user row data).
18
+ APP_LOOKUP_PAGE_SIZE = 100
19
+ APP_LOOKUP_MAX_PAGES = 10
20
+ APP_LOOKUP_BUDGET_MS = 5_000
21
+
22
+ FORBIDDEN_COLUMN_NAMES = %w[__proto__ constructor prototype].freeze
23
+ COLUMN_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
24
+
25
+ module_function
26
+
27
+ def _encode(value)
28
+ URI.encode_www_form_component(value.to_s)
29
+ end
30
+
31
+ def fetch_discovered_schema(client, tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
32
+ # The appId path is the route the `axhub` CLI uses and is verified to work
33
+ # with a data-ring PAT (2026-06). The slug `/inspect` route rejects a slug
34
+ # in the {tenant} path segment on the live backend ("tenant_id 형식이 잘못됐어요",
35
+ # HTTP 400) — a 400 not a 404, so the old slug-first order never reached the
36
+ # working path. appId is primary; slug inspect is a best-effort fallback. The
37
+ # appId error is the meaningful one, so it is what surfaces.
38
+ begin
39
+ _fetch_app_id_inspect(client, tenant_slug, app_slug, table)
40
+ rescue StandardError => err
41
+ begin
42
+ _fetch_slug_inspect(client, tenant_slug, app_slug, table)
43
+ rescue StandardError
44
+ raise _normalize_discover_error(err, table)
45
+ end
46
+ end
47
+ end
48
+
49
+ def _fetch_slug_inspect(client, tenant_slug, app_slug, table)
50
+ path = "/api/v1/tenants/#{_encode(tenant_slug)}/apps/#{_encode(app_slug)}/tables/#{_encode(table)}/inspect"
51
+ raw = client.request_raw('GET', path, camelize: true)
52
+ schema_from_inspect_result(table, raw)
53
+ end
54
+
55
+ def _fetch_app_id_inspect(client, tenant_slug, app_slug, table)
56
+ app_id = _resolve_app_id(client, tenant_slug, app_slug)
57
+ raise TableNotFoundError.new("Dynamic data table '#{table}' was not found") if app_id.nil? || app_id.empty?
58
+
59
+ path = "/api/v1/apps/#{_encode(app_id)}/tables/#{_encode(table)}"
60
+ raw = client.request_raw('GET', path, camelize: true)
61
+ schema_from_inspect_result(table, raw)
62
+ end
63
+
64
+ def _resolve_app_id(client, tenant_slug, app_slug)
65
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
66
+ cursor = nil
67
+ APP_LOOKUP_MAX_PAGES.times do |page|
68
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 - started_at > APP_LOOKUP_BUDGET_MS
69
+ raise IntrospectFailedError.new(
70
+ "app lookup budget exceeded (#{APP_LOOKUP_BUDGET_MS}ms) while searching for slug '#{app_slug}' in tenant '#{tenant_slug}'"
71
+ )
72
+ end
73
+ query = { 'tenant_slug' => tenant_slug, 'limit' => APP_LOOKUP_PAGE_SIZE }
74
+ query['cursor'] = cursor if cursor
75
+ raw = client.request_raw('GET', '/api/v1/apps', query: query, camelize: true)
76
+ raw ||= {}
77
+ items = raw['items'] || []
78
+ match = items.find { |app| app['slug'] == app_slug && app['id'].is_a?(String) }
79
+ return match['id'] if match && match['id']
80
+
81
+ # Empty page on the first request means the tenant truly has no apps.
82
+ return nil if page.zero? && items.empty?
83
+
84
+ next_cursor = raw['next_cursor'] || raw['nextCursor']
85
+ return nil if next_cursor.nil? || next_cursor == ''
86
+
87
+ cursor = next_cursor
88
+ end
89
+ raise ScanLimitExceededError.new(
90
+ "App lookup exceeded #{APP_LOOKUP_MAX_PAGES} pages x #{APP_LOOKUP_PAGE_SIZE} apps without finding slug '#{app_slug}'"
91
+ )
92
+ end
93
+
94
+ def _normalize_discover_error(err, table)
95
+ return err if err.is_a?(TableNotFoundError) || err.is_a?(IntrospectFailedError) || err.is_a?(ScanLimitExceededError)
96
+
97
+ if _not_found?(err)
98
+ return TableNotFoundError.new(
99
+ "Dynamic data table '#{table}' was not found",
100
+ request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
101
+ )
102
+ end
103
+ status = err.respond_to?(:status) ? err.status : nil
104
+ if status.is_a?(Integer) && status >= 500
105
+ return IntrospectFailedError.new(
106
+ "Failed to introspect dynamic data table '#{table}'",
107
+ status: status,
108
+ retryable: (err.respond_to?(:retryable) ? !!err.retryable : false),
109
+ request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
110
+ )
111
+ end
112
+ err
113
+ end
114
+
115
+ def schema_from_inspect_result(table, raw)
116
+ raw ||= {}
117
+ columns = raw['columns'] || []
118
+ shape = {}
119
+ columns.each do |column|
120
+ name = column['name']
121
+ next if FORBIDDEN_COLUMN_NAMES.include?(name)
122
+ next unless name.is_a?(String) && COLUMN_NAME_RE.match?(name)
123
+
124
+ shape[name] = _column_type_to_def(column['type'])
125
+ end
126
+ table_name = raw['tableName'] || raw['table_name'] || raw['name'] || table
127
+ Data.define_schema({ 'table' => table_name, 'columns' => shape })
128
+ end
129
+
130
+ def _column_type_to_def(col_type)
131
+ case col_type
132
+ when 'uuid' then 'uuid'
133
+ when 'int', 'integer', 'bigint' then 'integer'
134
+ when 'float', 'numeric', 'double precision', 'real' then 'number'
135
+ when 'bool', 'boolean' then 'boolean'
136
+ when 'timestamp', 'timestamptz', 'timestamp with time zone' then 'timestamp'
137
+ when 'json', 'jsonb' then 'json'
138
+ else 'string' # text / varchar / character varying / unknown -> string
139
+ end
140
+ end
141
+
142
+ def _not_found?(err)
143
+ return true if err.is_a?(TableNotFoundError)
144
+
145
+ err.is_a?(AxHub::Error) && err.respond_to?(:status) && err.status == 404
146
+ end
147
+ end
148
+ end