Skip to content

Commit 5b8c2dc

Browse files
jopemachineclaude
andcommitted
feat(BA-5829): add AppConfigFragment + AppConfig GQL surface
Adds the GraphQL exposure for AppConfigFragment and the merged AppConfig view (BEP-1052 §2 / §5). Service / processor additions (only what the GQL layer needs that is not already in BA-5827): - Merged-view actions: get_user_app_config, search_user_app_configs, admin_search_app_configs (single-row + paginated reads of the computed AppConfig view). - Service methods + processor wiring + supported_actions for those three actions. Adapter additions: - Merged-view methods: get_user_app_config / my_app_configs / admin_search_app_configs / public_app_config_fragments. `my_*` pulls the user from current_user() inside the adapter. - Bulk-only `my` mutations: my_bulk_create / my_bulk_update — bulk admin variants are wired in BA-5827 already. DTO additions: - common/dto/manager/v2/app_config/* — AppConfigNode, GetUserAppConfigInput / Payload, SearchAppConfigsInput / Payload, SearchMyAppConfigsInput, BulkCreate/UpdateMyAppConfigFragmentsPayload (the my-bulk payloads live here because they carry AppConfigNode). GraphQL: - app_config package: AppConfigGQL, my/admin/public root resolvers. - app_config_fragment package: scope-bound + admin queries, bulk-only mutations (admin + my variants), AppConfigFragmentGQL + AppConfigScopeTypeGQL + filter/order/key inputs. - DataLoader: app_config_fragment_loader for N+1 batching. - schema.py: register the new root queries / mutations. Resolves BA-5829. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49976dd commit 5b8c2dc

23 files changed

Lines changed: 282 additions & 124 deletions

File tree

src/ai/backend/common/dto/manager/v2/app_config/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
AppConfig (merged view) DTOs v2 for Manager API.
2+
AppConfig (merged view) DTOs v2 for Manager API (BEP-1052 §5).
33
"""
44

55
from .request import (
@@ -11,9 +11,9 @@
1111
)
1212
from .response import (
1313
AppConfigNode,
14+
BulkCreateMyAppConfigFragmentsPayload,
15+
BulkUpdateMyAppConfigFragmentsPayload,
1416
GetUserAppConfigPayload,
15-
MyBulkCreateAppConfigFragmentsPayload,
16-
MyBulkUpdateAppConfigFragmentsPayload,
1717
SearchAppConfigsPayload,
1818
)
1919
from .types import (
@@ -28,8 +28,8 @@
2828
"AppConfigOrder",
2929
"AppConfigOrderField",
3030
"AppConfigScopeType",
31-
"MyBulkCreateAppConfigFragmentsPayload",
32-
"MyBulkUpdateAppConfigFragmentsPayload",
31+
"BulkCreateMyAppConfigFragmentsPayload",
32+
"BulkUpdateMyAppConfigFragmentsPayload",
3333
"GetUserAppConfigInput",
3434
"GetUserAppConfigPayload",
3535
"OrderDirection",

src/ai/backend/common/dto/manager/v2/app_config/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Request DTOs for AppConfig (merged view) DTO v2.
2+
Request DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5).
33
"""
44

55
from __future__ import annotations
@@ -62,7 +62,7 @@ class SearchMyAppConfigsInput(_AppConfigSearchInputBase):
6262
"""Input for self-service merged-view search (`/v2/app-configs/my/search`).
6363
6464
The adapter pins the caller as the user scope; no `user_id` argument
65-
is accepted here.
65+
is accepted here (BEP-1052 §5 — `filter.userId` is ignored).
6666
"""
6767

6868

src/ai/backend/common/dto/manager/v2/app_config/response.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Response DTOs for AppConfig (merged view) DTO v2.
2+
Response DTOs for AppConfig (merged view) DTO v2 (BEP-1052 §5).
33
"""
44

55
from __future__ import annotations
@@ -17,15 +17,15 @@
1717

1818
__all__ = (
1919
"AppConfigNode",
20-
"MyBulkCreateAppConfigFragmentsPayload",
21-
"MyBulkUpdateAppConfigFragmentsPayload",
20+
"BulkCreateMyAppConfigFragmentsPayload",
21+
"BulkUpdateMyAppConfigFragmentsPayload",
2222
"GetUserAppConfigPayload",
2323
"SearchAppConfigsPayload",
2424
)
2525

2626

2727
class AppConfigNode(BaseResponseModel):
28-
"""Merged per-user AppConfig view.
28+
"""Merged per-user AppConfig view (BEP-1052 §5).
2929
3030
`fragments` are ordered low → high merge priority (matching the
3131
policy's `scope_sources`). `config` is the deep-merged result,
@@ -62,11 +62,11 @@ class SearchAppConfigsPayload(BaseResponseModel):
6262
has_previous_page: bool = Field(default=False, description="Whether there is a previous page.")
6363

6464

65-
class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel):
66-
"""Payload for `myBulkCreateAppConfigFragments`.
65+
class BulkCreateMyAppConfigFragmentsPayload(BaseResponseModel):
66+
"""Payload for `bulkCreateMyAppConfigFragments`.
6767
6868
Each successfully created row produces a recomputed merged
69-
`AppConfigNode`; failures are collected per-item.
69+
`AppConfigNode`; failures are collected per-item (BEP-1052 §3).
7070
"""
7171

7272
created: list[AppConfigNode] = Field(
@@ -75,8 +75,8 @@ class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel):
7575
failed: list[AppConfigFragmentBulkError] = Field(description="Per-item failures.")
7676

7777

78-
class MyBulkUpdateAppConfigFragmentsPayload(BaseResponseModel):
79-
"""Payload for `myBulkUpdateAppConfigFragments`."""
78+
class BulkUpdateMyAppConfigFragmentsPayload(BaseResponseModel):
79+
"""Payload for `bulkUpdateMyAppConfigFragments`."""
8080

8181
updated: list[AppConfigNode] = Field(
8282
description="Recomputed merged AppConfig views for each updated USER fragment.",

src/ai/backend/common/dto/manager/v2/app_config/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Common types for AppConfig (merged view) DTO v2.
2+
Common types for AppConfig (merged view) DTO v2 (BEP-1052 §5).
33
"""
44

55
from __future__ import annotations

src/ai/backend/manager/api/adapters/app_config_fragment.py

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
from ai.backend.common.dto.manager.v2.app_config_fragment.types import (
5353
AppConfigScopeType as DTOAppConfigScopeType,
5454
)
55+
from ai.backend.common.dto.manager.v2.app_config_fragment.types import OrderDirection
56+
from ai.backend.common.exception import UnreachableError
5557
from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec
5658
from ai.backend.manager.data.app_config.types import AppConfigData
5759
from ai.backend.manager.data.app_config_fragment.bulk_types import (
@@ -110,10 +112,8 @@
110112
class AppConfigFragmentAdapter(BaseAdapter):
111113
"""Adapter for AppConfigFragment raw-row operations.
112114
113-
Writes are bulk-only; single-item create / update /
114-
purge entry points are intentionally absent. Self-service my_bulk
115-
writes (which return the recomputed merged view) live on
116-
`AppConfigAdapter` alongside the merged-view reads.
115+
Writes are bulk-only (BEP-1052 §3); single-item create / update /
116+
purge entry points are intentionally absent.
117117
"""
118118

119119
async def get(self, key_input: AppConfigFragmentKeyInput) -> GetAppConfigFragmentPayload:
@@ -265,7 +265,143 @@ def _data_to_dto(data: AppConfigFragmentData) -> AppConfigFragmentNode:
265265
updated_at=data.updated_at,
266266
)
267267

268-
# ── Bulk mutations ───────────────────────────────
268+
# ── Merged-view (AppConfig, BEP-1052 §5) ───────────────────────
269+
270+
async def my_app_config(self, name: str) -> GetUserAppConfigPayload:
271+
"""Read the caller's own merged AppConfig for `name`.
272+
273+
Resolves the current user from the context; there is no way to
274+
target another user through this method.
275+
"""
276+
me = current_user()
277+
if me is None:
278+
raise UnreachableError("User context is not available")
279+
result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete(
280+
GetUserAppConfigAction(user_id=me.user_id, config_name=name)
281+
)
282+
return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config))
283+
284+
async def admin_get_user_app_config(
285+
self, input: GetUserAppConfigInput
286+
) -> GetUserAppConfigPayload:
287+
"""Read a specific user's merged AppConfig (admin only)."""
288+
result = await self._processors.app_config_fragment.get_user_app_config.wait_for_complete(
289+
GetUserAppConfigAction(user_id=input.user_id, config_name=input.name)
290+
)
291+
return GetUserAppConfigPayload(item=self._app_config_data_to_dto(result.app_config))
292+
293+
async def my_search_app_configs(
294+
self, input: SearchMyAppConfigsInput
295+
) -> SearchAppConfigsPayload:
296+
"""Paginated merged-view search over the caller's own AppConfigs."""
297+
me = current_user()
298+
if me is None:
299+
raise UnreachableError("User context is not available")
300+
querier = self._build_app_config_querier(input)
301+
result = (
302+
await self._processors.app_config_fragment.search_user_app_configs.wait_for_complete(
303+
SearchUserAppConfigsAction(
304+
scope=UserAppConfigSearchScope(user_id=me.user_id),
305+
querier=querier,
306+
)
307+
)
308+
)
309+
return SearchAppConfigsPayload(
310+
items=[self._app_config_data_to_dto(item) for item in result.items],
311+
total_count=result.total_count,
312+
has_next_page=result.has_next_page,
313+
has_previous_page=result.has_previous_page,
314+
)
315+
316+
async def admin_search_app_configs(
317+
self, input: SearchAppConfigsInput
318+
) -> SearchAppConfigsPayload:
319+
"""Cross-user merged-view search (admin only).
320+
321+
`filter.user_id` pins the query to a single user; otherwise
322+
pagination walks across every user.
323+
"""
324+
querier = self._build_app_config_querier(input)
325+
result = (
326+
await self._processors.app_config_fragment.admin_search_app_configs.wait_for_complete(
327+
AdminSearchAppConfigsAction(querier=querier)
328+
)
329+
)
330+
return SearchAppConfigsPayload(
331+
items=[self._app_config_data_to_dto(item) for item in result.items],
332+
total_count=result.total_count,
333+
has_next_page=result.has_next_page,
334+
has_previous_page=result.has_previous_page,
335+
)
336+
337+
def _build_app_config_querier(
338+
self,
339+
input: SearchMyAppConfigsInput | SearchAppConfigsInput,
340+
) -> BatchQuerier:
341+
"""Querier builder shared by `my_search_app_configs` and
342+
`admin_search_app_configs`.
343+
344+
The merged-view SQL resolves cursor/order internally via the
345+
repository layer; this helper forwards only the filter / order /
346+
pagination fields so cursor tiebreakers stay consistent with
347+
the raw-fragment querier.
348+
"""
349+
conditions = self._convert_app_config_filter(input.filter) if input.filter else []
350+
orders = self._convert_app_config_orders(input.order) if input.order else []
351+
return self._build_querier(
352+
conditions=conditions,
353+
orders=orders,
354+
pagination_spec=self._PAGINATION_SPEC,
355+
first=input.first,
356+
after=input.after,
357+
last=input.last,
358+
before=input.before,
359+
limit=input.limit,
360+
offset=input.offset,
361+
)
362+
363+
def _convert_app_config_filter(self, filter: AppConfigFilter) -> list[QueryCondition]:
364+
conditions: list[QueryCondition] = []
365+
if filter.name is not None:
366+
condition = self.convert_string_filter(
367+
filter.name,
368+
contains_factory=AppConfigFragmentConditions.by_name_contains,
369+
equals_factory=AppConfigFragmentConditions.by_name_equals,
370+
starts_with_factory=AppConfigFragmentConditions.by_name_starts_with,
371+
ends_with_factory=AppConfigFragmentConditions.by_name_ends_with,
372+
in_factory=AppConfigFragmentConditions.by_name_in,
373+
)
374+
if condition is not None:
375+
conditions.append(condition)
376+
# `filter.user_id` handling lives inside the merged-view SQL
377+
# (repository layer) rather than in a BatchQuerier condition —
378+
# see `AppConfigFragmentDBSource.admin_search_app_configs`.
379+
return conditions
380+
381+
@staticmethod
382+
def _convert_app_config_orders(orders: list[AppConfigOrder]) -> list[QueryOrder]:
383+
result: list[QueryOrder] = []
384+
for order in orders:
385+
ascending = order.direction == OrderDirection.ASC
386+
match order.field:
387+
case AppConfigOrderField.NAME:
388+
result.append(AppConfigFragmentOrders.name(ascending))
389+
case AppConfigOrderField.USER_ID:
390+
# USER_ID ordering is applied inside the merged-view SQL
391+
# because the raw `app_config_fragments` row does not
392+
# carry a user_id column directly.
393+
continue
394+
return result
395+
396+
def _app_config_data_to_dto(self, data: AppConfigData) -> AppConfigNode:
397+
return AppConfigNode(
398+
user_id=data.user_id,
399+
name=data.name,
400+
fragments=[self._data_to_dto(fragment) for fragment in data.fragments],
401+
config=dict(data.config) if data.config is not None else None,
402+
)
403+
404+
# ── Bulk mutations (BEP-1052 §3) ───────────────────────────────
269405
#
270406
# Each bulk processor returns a `BulkProcessResult[T]` whose
271407
# `.result` field is the underlying `*ActionResult` produced by the

src/ai/backend/manager/api/gql/app_config/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""AppConfig (merged view) GraphQL API package."""
1+
"""AppConfig (merged view) GraphQL API package (BEP-1052 §5)."""
22

33
from .resolver import (
44
admin_app_configs,

src/ai/backend/manager/api/gql/app_config/resolver/query.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""AppConfig (merged view) GQL query resolvers."""
1+
"""AppConfig (merged view) GQL query resolvers (BEP-1052 §5)."""
22

33
from __future__ import annotations
44

@@ -36,7 +36,7 @@
3636
added_version=NEXT_RELEASE_VERSION,
3737
description=(
3838
"Caller's own merged AppConfig list (auth required). Chain per policy "
39-
"; the adapter pins `(USER, current_user)` internally."
39+
"(BEP-1052 §5); the adapter pins `(USER, current_user)` internally."
4040
),
4141
)
4242
) # type: ignore[misc]
@@ -108,7 +108,7 @@ async def admin_app_configs(
108108
added_version=NEXT_RELEASE_VERSION,
109109
description=(
110110
"Public (no-auth) `PUBLIC`-scope app-config fragments — the subset of "
111-
"raw fragments that carry no personally-scoped data."
111+
"raw fragments that carry no personally-scoped data (BEP-1052 §3)."
112112
),
113113
)
114114
) # type: ignore[misc]
Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
from .bulk_payloads import (
2-
MyBulkCreateAppConfigFragmentsPayloadGQL,
3-
MyBulkUpdateAppConfigFragmentsPayloadGQL,
4-
)
51
from .filters import (
62
AppConfigFilterGQL,
73
AppConfigOrderByGQL,
@@ -14,6 +10,4 @@
1410
"AppConfigGQL",
1511
"AppConfigOrderByGQL",
1612
"AppConfigOrderFieldGQL",
17-
"MyBulkCreateAppConfigFragmentsPayloadGQL",
18-
"MyBulkUpdateAppConfigFragmentsPayloadGQL",
1913
]

src/ai/backend/manager/api/gql/app_config/types/node.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""AppConfig (merged view) GQL output types."""
1+
"""AppConfig (merged view) GQL output types (BEP-1052 §5)."""
22

33
from __future__ import annotations
44

@@ -22,7 +22,7 @@
2222
BackendAIGQLMeta(
2323
added_version=NEXT_RELEASE_VERSION,
2424
description=(
25-
"Merged per-user AppConfig view. `fragments` are ordered "
25+
"Merged per-user AppConfig view (BEP-1052 §5). `fragments` are ordered "
2626
"low → high merge priority; `config` is the deep-merge result and is "
2727
"null when every contributing fragment is empty."
2828
),

src/ai/backend/manager/api/gql/app_config_fragment/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
admin_bulk_purge_app_config_fragments,
77
admin_bulk_update_app_config_fragments,
88
app_config_fragment,
9-
my_bulk_create_app_config_fragments,
10-
my_bulk_update_app_config_fragments,
9+
bulk_create_my_app_config_fragments,
10+
bulk_update_my_app_config_fragments,
11+
scoped_app_config_fragments,
1112
)
1213
from .types import (
1314
AppConfigFragmentFilterGQL,
@@ -19,16 +20,16 @@
1920
)
2021

2122
__all__ = [
22-
# Queries — scope-bound list belongs on DomainV2 / UserV2 child fields
23+
# Queries
2324
"app_config_fragment",
2425
"scoped_app_config_fragments",
2526
"admin_app_config_fragments",
26-
# Bulk mutations
27+
# Bulk mutations (BEP-1052 §3 — bulk-only)
2728
"admin_bulk_create_app_config_fragments",
2829
"admin_bulk_update_app_config_fragments",
2930
"admin_bulk_purge_app_config_fragments",
30-
"my_bulk_create_app_config_fragments",
31-
"my_bulk_update_app_config_fragments",
31+
"bulk_create_my_app_config_fragments",
32+
"bulk_update_my_app_config_fragments",
3233
# Types
3334
"AppConfigFragmentGQL",
3435
"AppConfigScopeTypeGQL",

0 commit comments

Comments
 (0)