axhub-sdk 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 572be1a63c2d4f482f151942ba1b90e92c82789be69b56eee536d3b2c7ea9a3d
4
- data.tar.gz: f93cb282b2c3919ac41633345bf3a396879d0228c3b635d4be032aa4894f3b59
3
+ metadata.gz: 87c5676568c02cbf7dd18a197c0f44931632dcf9e22facb4911b066fdd7ceb6d
4
+ data.tar.gz: 8a1558e6f13a915c471efdaacff64d856a4616e25a875e88c51193faf10d3204
5
5
  SHA512:
6
- metadata.gz: fd0379f32050fdd452634e294d775af5291799c0370c7e4b4a696882335de68dda77560ea83a965445bffff368839ff6e3d2b87f5d3e8f45b45b0215449dd647
7
- data.tar.gz: bae059dbb674d23c7a584912367b0b644e6a5ef5fc0790e44f02b68f5ec3145027dcec11afda99436cd2c950f646ac428363797f570ae403ed92135e6da87207
6
+ metadata.gz: 783981a17b4e57b815ae92c33a867953ad05151c1cfb4266f5c258531c4b9461eaa34f4e0be6b10711a03dcfabd1fbb64e7d48835f06ee4388722de72463413a
7
+ data.tar.gz: eae4cb33b36bb93274710c0510915251d9824b950bc7213b374d2d9d449a1931300321a026afb2f48de7df843a99b202828b1b4b08199cfc84d7ca1e0bb9bdb3
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,267 @@
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
+ # The AxHub data ring rejects an unfiltered list with HTTP 400
97
+ # ("최소 1개의 WHERE 필터가 필요해요") as a deliberate mass-scan guard —
98
+ # confirmed live 2026-06, mirrored by the `axhub data` CLI. Checked after
99
+ # cursor/page validation so a malformed cursor still surfaces first.
100
+ if where.nil?
101
+ raise ValidationError.new(
102
+ 'AxHub data list requires at least one WHERE filter (the backend rejects unfiltered scans). Pass `where:`.',
103
+ 'where_required'
104
+ )
105
+ end
106
+ query = Data.serialize_where(where).dup
107
+ query['per_page'] = per_page unless per_page.nil?
108
+ query['page'] = resolved_page if resolved_page != 1
109
+ sort = Data.serialize_order_by(order_by)
110
+ query['sort'] = sort if sort && sort != ''
111
+ serialized_select = Data.serialize_select(select)
112
+ query['_select'] = serialized_select unless serialized_select.nil?
113
+ raw = @client.request_raw('GET', _path, query: query) || {}
114
+ items = Data.project_rows(raw['items'] || [], select)
115
+ # mirrors node: current_page falls back to the requested page, has_next
116
+ # reads the backend `has_more` flag verbatim, has_prev derives client-side.
117
+ current_page = raw['page'].nil? ? resolved_page : raw['page']
118
+ has_next = !!(raw['has_more'] || false)
119
+ has_prev = current_page > 1
120
+ PaginatedList.new(
121
+ items: items,
122
+ next_cursor: has_next ? (current_page + 1).to_s : nil,
123
+ first_cursor: has_prev ? (current_page - 1).to_s : nil,
124
+ has_next: has_next,
125
+ has_prev: has_prev,
126
+ total_is_exact: false
127
+ )
128
+ end
129
+
130
+ def list_all(where: nil, order_by: nil, select: nil, page_size: nil, limit: nil, &block)
131
+ base = { where: where, order_by: order_by, select: select, limit: limit }
132
+ fetcher = lambda do |p|
133
+ kwargs = base.reject { |_k, v| v.nil? }
134
+ kwargs[:cursor] = p[:cursor] unless p[:cursor].nil?
135
+ ps = p[:page_size].nil? ? page_size : p[:page_size]
136
+ kwargs[:page_size] = ps unless ps.nil?
137
+ list(**kwargs)
138
+ end
139
+ Data.list_all(fetcher, { page_size: page_size }, &block)
140
+ end
141
+
142
+ def count(where: nil)
143
+ # Same mass-scan guard as list() — the backend 400s an unfiltered count.
144
+ if where.nil?
145
+ raise ValidationError.new(
146
+ 'AxHub data count requires at least one WHERE filter (the backend rejects unfiltered scans). Pass `where:`.',
147
+ 'where_required'
148
+ )
149
+ end
150
+ raw = @client.request_raw('GET', "#{_path}/_count", query: Data.serialize_where(where)) || {}
151
+ raw['count']
152
+ end
153
+
154
+ def get(row_id, select: nil)
155
+ Data.validate_select_columns(@schema, select)
156
+ serialized_select = Data.serialize_select(select)
157
+ query = serialized_select.nil? ? {} : { '_select' => serialized_select }
158
+ row = @client.request_raw('GET', _path(row_id), query: query) || {}
159
+ Data.project_row(row, select)
160
+ end
161
+
162
+ def insert(row)
163
+ Data.run_schema_validation(@schema, row, 'insert')
164
+ @client.request_raw('POST', _path, body: row)
165
+ end
166
+
167
+ def insert_many(rows)
168
+ rows.each { |row| Data.run_schema_validation(@schema, row, 'insert') }
169
+ # mirrors node: no bulk endpoint exists, so insertMany loops single
170
+ # inserts and returns { items, count }.
171
+ items = rows.map { |row| insert(row) }
172
+ { 'items' => items, 'count' => items.length }
173
+ end
174
+
175
+ def update(row_id, patch)
176
+ Data.run_schema_validation(@schema, patch, 'update')
177
+ @client.request_raw('PATCH', _path(row_id), body: patch)
178
+ end
179
+
180
+ def delete(row_id)
181
+ @client.request_raw('DELETE', _path(row_id))
182
+ nil
183
+ end
184
+
185
+ private
186
+
187
+ def _path(row_id = nil)
188
+ base = "/data/#{Data._encode(@tenant_slug)}/#{Data._encode(@app_slug)}/#{Data._encode(@table_name)}"
189
+ row_id.nil? ? base : "#{base}/#{Data._encode(row_id)}"
190
+ end
191
+ end
192
+
193
+ class AppDataFactory
194
+ def initialize(data, tenant_slug, app_slug)
195
+ @data = data
196
+ @tenant_slug = tenant_slug
197
+ @app_slug = app_slug
198
+ end
199
+
200
+ def table(table)
201
+ @data.table(@tenant_slug, @app_slug, table)
202
+ end
203
+
204
+ def discover(table, fresh: nil, ttl_ms: nil)
205
+ @data.discover(@tenant_slug, @app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
206
+ end
207
+
208
+ def invalidate_schema(table = nil)
209
+ if table.nil?
210
+ @data.invalidate_schema
211
+ else
212
+ @data.invalidate_schema(@tenant_slug, @app_slug, table)
213
+ end
214
+ end
215
+ end
216
+
217
+ class TenantDataFactory
218
+ def initialize(data, tenant_slug)
219
+ @data = data
220
+ @tenant_slug = tenant_slug
221
+ end
222
+
223
+ def app(app_slug)
224
+ AppDataFactory.new(@data, @tenant_slug, app_slug)
225
+ end
226
+ end
227
+
228
+ # Entry point for the ergonomic data layer; holds the per-client schema cache
229
+ # used by discover() (mirrors node DataClient).
230
+ class DataClient
231
+ def initialize(client, schema_cache: nil)
232
+ @client = client
233
+ @schema_cache = case schema_cache
234
+ when SchemaCache then schema_cache
235
+ when Hash then SchemaCache.new(**schema_cache.transform_keys(&:to_sym))
236
+ else SchemaCache.new
237
+ end
238
+ end
239
+
240
+ def table(tenant_slug, app_slug, table)
241
+ schema = table.is_a?(DataTableSchema) ? table : nil
242
+ table_name = table.is_a?(DataTableSchema) ? table.table : table
243
+ DataTableClient.new(@client, tenant_slug, app_slug, table_name, schema)
244
+ end
245
+
246
+ def scoped(tenant_slug)
247
+ TenantDataFactory.new(self, tenant_slug)
248
+ end
249
+
250
+ def discover(tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
251
+ key = Data.schema_cache_key(tenant_slug, app_slug, table)
252
+ schema = @schema_cache.get_or_set(key, fresh: fresh, ttl_ms: ttl_ms) do
253
+ Data.fetch_discovered_schema(@client, tenant_slug, app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
254
+ end
255
+ DataTableClient.new(@client, tenant_slug, app_slug, schema.table, schema)
256
+ end
257
+
258
+ def invalidate_schema(tenant_slug = nil, app_slug = nil, table = nil)
259
+ if !tenant_slug.nil? && !app_slug.nil? && !table.nil?
260
+ @schema_cache.invalidate(Data.schema_cache_key(tenant_slug, app_slug, table))
261
+ return
262
+ end
263
+ @schema_cache.invalidate
264
+ end
265
+ end
266
+ end
267
+ 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