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 +4 -4
- data/README.md +154 -4
- data/axhub-sdk.gemspec +3 -0
- data/lib/axhub_sdk/data/client.rb +267 -0
- data/lib/axhub_sdk/data/discover.rb +148 -0
- data/lib/axhub_sdk/data/dsl/ops.rb +150 -0
- data/lib/axhub_sdk/data/dsl/schema.rb +66 -0
- data/lib/axhub_sdk/data/dsl/validation.rb +60 -0
- data/lib/axhub_sdk/data/errors.rb +48 -0
- data/lib/axhub_sdk/data/pagination.rb +100 -0
- data/lib/axhub_sdk/data/projection.rb +50 -0
- data/lib/axhub_sdk/data/schema_cache.rb +105 -0
- data/lib/axhub_sdk/data/where_serializer.rb +84 -0
- data/lib/axhub_sdk/data.rb +67 -0
- data/lib/axhub_sdk/version.rb +1 -1
- data/lib/axhub_sdk.rb +27 -2
- metadata +42 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87c5676568c02cbf7dd18a197c0f44931632dcf9e22facb4911b066fdd7ceb6d
|
|
4
|
+
data.tar.gz: 8a1558e6f13a915c471efdaacff64d856a4616e25a875e88c51193faf10d3204
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
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
|
@@ -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
|