fix(user): implement ListUsers pagination
Rework ListUsers to match the ListMemos pagination contract: opaque PageToken, normalizePageSize, DB-level limit+1 look-ahead, and a next_page_token. Adds Offset to store.FindUser with an OFFSET clause in all three dialects, and an `id DESC` ORDER BY tiebreaker so offset pages stay stable when created_ts ties. Also align pagination across list endpoints: - Bump DefaultPageSize 10 -> 50 to match the documented default; use normalizePageSize in ListAttachments. - Remove the never-implemented total_size field from all six list responses (ListUsers, ListAttachments, ListMemoComments, ListMemoReactions, ListUserSettings, ListPersonalAccessTokens) and regenerate. - useListUsers now pages through next_page_token so the admin members view still loads every user past the default page size.
This commit is contained in:
@@ -149,9 +149,6 @@ message ListAttachmentsResponse {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of attachments (may be approximate).
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message GetAttachmentRequest {
|
||||
|
||||
@@ -519,9 +519,6 @@ message ListMemoCommentsResponse {
|
||||
|
||||
// A token for the next page of results.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of comments.
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message ListMemoReactionsRequest {
|
||||
@@ -545,9 +542,6 @@ message ListMemoReactionsResponse {
|
||||
|
||||
// A token for the next page of results.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of reactions.
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message UpsertMemoReactionRequest {
|
||||
|
||||
@@ -281,9 +281,6 @@ message ListUsersResponse {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of users (may be approximate).
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message BatchGetUsersRequest {
|
||||
@@ -529,9 +526,6 @@ message ListUserSettingsResponse {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of settings (may be approximate).
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
// LinkedIdentity represents an SSO identity linked to a user account.
|
||||
@@ -663,9 +657,6 @@ message ListPersonalAccessTokensResponse {
|
||||
|
||||
// A token for the next page of results.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of personal access tokens.
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message CreatePersonalAccessTokenRequest {
|
||||
|
||||
@@ -463,8 +463,6 @@ type ListAttachmentsResponse struct {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of attachments (may be approximate).
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -513,13 +511,6 @@ func (x *ListAttachmentsResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListAttachmentsResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetAttachmentRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Required. The attachment name of the attachment to retrieve.
|
||||
@@ -746,12 +737,10 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"page_token\x18\x02 \x01(\tB\x03\xe0A\x01R\tpageToken\x12\x1b\n" +
|
||||
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter\x12\x1e\n" +
|
||||
"\border_by\x18\x04 \x01(\tB\x03\xe0A\x01R\aorderBy\"\x9c\x01\n" +
|
||||
"\border_by\x18\x04 \x01(\tB\x03\xe0A\x01R\aorderBy\"}\n" +
|
||||
"\x17ListAttachmentsResponse\x12:\n" +
|
||||
"\vattachments\x18\x01 \x03(\v2\x18.memos.api.v1.AttachmentR\vattachments\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"K\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"K\n" +
|
||||
"\x14GetAttachmentRequest\x123\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
|
||||
"\x17memos.api.v1/AttachmentR\x04name\"\x9a\x01\n" +
|
||||
|
||||
@@ -1399,8 +1399,6 @@ type ListMemoCommentsResponse struct {
|
||||
Memos []*Memo `protobuf:"bytes,1,rep,name=memos,proto3" json:"memos,omitempty"`
|
||||
// A token for the next page of results.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of comments.
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1449,13 +1447,6 @@ func (x *ListMemoCommentsResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListMemoCommentsResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListMemoReactionsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Required. The resource name of the memo.
|
||||
@@ -1526,8 +1517,6 @@ type ListMemoReactionsResponse struct {
|
||||
Reactions []*Reaction `protobuf:"bytes,1,rep,name=reactions,proto3" json:"reactions,omitempty"`
|
||||
// A token for the next page of results.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of reactions.
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1576,13 +1565,6 @@ func (x *ListMemoReactionsResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListMemoReactionsResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type UpsertMemoReactionRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Required. The resource name of the memo.
|
||||
@@ -2456,23 +2438,19 @@ const file_api_v1_memo_service_proto_rawDesc = "" +
|
||||
"\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" +
|
||||
"\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\x12\x1e\n" +
|
||||
"\border_by\x18\x04 \x01(\tB\x03\xe0A\x01R\aorderBy\"\x8b\x01\n" +
|
||||
"\border_by\x18\x04 \x01(\tB\x03\xe0A\x01R\aorderBy\"l\n" +
|
||||
"\x18ListMemoCommentsResponse\x12(\n" +
|
||||
"\x05memos\x18\x01 \x03(\v2\x12.memos.api.v1.MemoR\x05memos\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"\x8f\x01\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x8f\x01\n" +
|
||||
"\x18ListMemoReactionsRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/MemoR\x04name\x12 \n" +
|
||||
"\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" +
|
||||
"\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"\x98\x01\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"y\n" +
|
||||
"\x19ListMemoReactionsResponse\x124\n" +
|
||||
"\treactions\x18\x01 \x03(\v2\x16.memos.api.v1.ReactionR\treactions\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"\x83\x01\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x83\x01\n" +
|
||||
"\x19UpsertMemoReactionRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/MemoR\x04name\x127\n" +
|
||||
|
||||
@@ -454,8 +454,6 @@ type ListUsersResponse struct {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of users (may be approximate).
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -504,13 +502,6 @@ func (x *ListUsersResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListUsersResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type BatchGetUsersRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Usernames []string `protobuf:"bytes,1,rep,name=usernames,proto3" json:"usernames,omitempty"`
|
||||
@@ -1384,8 +1375,6 @@ type ListUserSettingsResponse struct {
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of settings (may be approximate).
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1434,13 +1423,6 @@ func (x *ListUserSettingsResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListUserSettingsResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LinkedIdentity represents an SSO identity linked to a user account.
|
||||
type LinkedIdentity struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -1927,8 +1909,6 @@ type ListPersonalAccessTokensResponse struct {
|
||||
PersonalAccessTokens []*PersonalAccessToken `protobuf:"bytes,1,rep,name=personal_access_tokens,json=personalAccessTokens,proto3" json:"personal_access_tokens,omitempty"`
|
||||
// A token for the next page of results.
|
||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
// The total count of personal access tokens.
|
||||
TotalSize int32 `protobuf:"varint,3,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1977,13 +1957,6 @@ func (x *ListPersonalAccessTokensResponse) GetNextPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListPersonalAccessTokensResponse) GetTotalSize() int32 {
|
||||
if x != nil {
|
||||
return x.TotalSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type CreatePersonalAccessTokenRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Required. The parent resource where this token will be created.
|
||||
@@ -3408,12 +3381,10 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"page_token\x18\x02 \x01(\tB\x03\xe0A\x01R\tpageToken\x12\x1b\n" +
|
||||
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter\x12&\n" +
|
||||
"\fshow_deleted\x18\x04 \x01(\bB\x03\xe0A\x01R\vshowDeleted\"\x84\x01\n" +
|
||||
"\fshow_deleted\x18\x04 \x01(\bB\x03\xe0A\x01R\vshowDeleted\"e\n" +
|
||||
"\x11ListUsersResponse\x12(\n" +
|
||||
"\x05users\x18\x01 \x03(\v2\x12.memos.api.v1.UserR\x05users\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"4\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"4\n" +
|
||||
"\x14BatchGetUsersRequest\x12\x1c\n" +
|
||||
"\tusernames\x18\x01 \x03(\tR\tusernames\"A\n" +
|
||||
"\x15BatchGetUsersResponse\x12(\n" +
|
||||
@@ -3504,12 +3475,10 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\x11memos.api.v1/UserR\x06parent\x12 \n" +
|
||||
"\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" +
|
||||
"\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"\x98\x01\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"y\n" +
|
||||
"\x18ListUserSettingsResponse\x125\n" +
|
||||
"\bsettings\x18\x01 \x03(\v2\x19.memos.api.v1.UserSettingR\bsettings\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"\x84\x02\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x84\x02\n" +
|
||||
"\x0eLinkedIdentity\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" +
|
||||
"\bidp_name\x18\x02 \x01(\tB%\xe0A\x03\xfaA\x1f\n" +
|
||||
@@ -3551,12 +3520,10 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
||||
"\x11memos.api.v1/UserR\x06parent\x12 \n" +
|
||||
"\tpage_size\x18\x02 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" +
|
||||
"\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"\xc2\x01\n" +
|
||||
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"\xa3\x01\n" +
|
||||
" ListPersonalAccessTokensResponse\x12W\n" +
|
||||
"\x16personal_access_tokens\x18\x01 \x03(\v2!.memos.api.v1.PersonalAccessTokenR\x14personalAccessTokens\x12&\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" +
|
||||
"\n" +
|
||||
"total_size\x18\x03 \x01(\x05R\ttotalSize\"\xa9\x01\n" +
|
||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa9\x01\n" +
|
||||
" CreatePersonalAccessTokenRequest\x121\n" +
|
||||
"\x06parent\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/UserR\x06parent\x12%\n" +
|
||||
|
||||
@@ -3058,10 +3058,6 @@ components:
|
||||
description: |-
|
||||
A token that can be sent as `page_token` to retrieve the next page.
|
||||
If this field is omitted, there are no subsequent pages.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of attachments (may be approximate).
|
||||
format: int32
|
||||
ListIdentityProvidersResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3100,10 +3096,6 @@ components:
|
||||
nextPageToken:
|
||||
type: string
|
||||
description: A token for the next page of results.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of comments.
|
||||
format: int32
|
||||
ListMemoReactionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3115,10 +3107,6 @@ components:
|
||||
nextPageToken:
|
||||
type: string
|
||||
description: A token for the next page of results.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of reactions.
|
||||
format: int32
|
||||
ListMemoRelationsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3162,10 +3150,6 @@ components:
|
||||
nextPageToken:
|
||||
type: string
|
||||
description: A token for the next page of results.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of personal access tokens.
|
||||
format: int32
|
||||
ListShortcutsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3196,10 +3180,6 @@ components:
|
||||
description: |-
|
||||
A token that can be sent as `page_token` to retrieve the next page.
|
||||
If this field is omitted, there are no subsequent pages.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of settings (may be approximate).
|
||||
format: int32
|
||||
description: Response message for ListUserSettings method.
|
||||
ListUserWebhooksResponse:
|
||||
type: object
|
||||
@@ -3222,10 +3202,6 @@ components:
|
||||
description: |-
|
||||
A token that can be sent as `page_token` to retrieve the next page.
|
||||
If this field is omitted, there are no subsequent pages.
|
||||
totalSize:
|
||||
type: integer
|
||||
description: The total count of users (may be approximate).
|
||||
format: int32
|
||||
Location:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -194,14 +194,7 @@ func (s *APIV1Service) ListAttachments(ctx context.Context, request *v1pb.ListAt
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
|
||||
// Set default page size
|
||||
pageSize := int(request.PageSize)
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 1000 {
|
||||
pageSize = 1000
|
||||
}
|
||||
pageSize := normalizePageSize(request.PageSize)
|
||||
|
||||
// Parse page token for offset
|
||||
offset := 0
|
||||
@@ -238,10 +231,6 @@ func (s *APIV1Service) ListAttachments(ctx context.Context, request *v1pb.ListAt
|
||||
response.Attachments = append(response.Attachments, convertAttachmentFromStore(attachment))
|
||||
}
|
||||
|
||||
// For simplicity, set total size to the number of returned attachments.
|
||||
// In a full implementation, you'd want a separate count query
|
||||
response.TotalSize = int32(len(response.Attachments))
|
||||
|
||||
// Set next page token if we got the full page size (indicating there might be more)
|
||||
if len(attachments) == pageSize {
|
||||
response.NextPageToken = fmt.Sprintf("%d", offset+pageSize)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const (
|
||||
// DefaultPageSize is the default page size for requests.
|
||||
DefaultPageSize = 10
|
||||
DefaultPageSize = 50
|
||||
// MaxPageSize is the maximum page size for requests.
|
||||
MaxPageSize = 1000
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
// drainListUsers walks every page of ListUsers and returns the concatenated users.
|
||||
func drainListUsers(ctx context.Context, t *testing.T, ts *TestService, pageSize int32) []*apiv1.User {
|
||||
t.Helper()
|
||||
var all []*apiv1.User
|
||||
pageToken := ""
|
||||
for {
|
||||
resp, err := ts.Service.ListUsers(ctx, &apiv1.ListUsersRequest{
|
||||
PageSize: pageSize,
|
||||
PageToken: pageToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
all = append(all, resp.Users...)
|
||||
if resp.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
pageToken = resp.NextPageToken
|
||||
require.LessOrEqual(t, len(all), 1000, "pagination did not terminate")
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func TestListUsersPagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
// Admin + 5 members = 6 users total.
|
||||
expected := map[string]struct{}{"admin": {}}
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("member-%d", i)
|
||||
_, err := ts.CreateRegularUser(ctx, name)
|
||||
require.NoError(t, err)
|
||||
expected[name] = struct{}{}
|
||||
}
|
||||
|
||||
// A partial first page must carry a next page token.
|
||||
first, err := ts.Service.ListUsers(adminCtx, &apiv1.ListUsersRequest{PageSize: 2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, first.Users, 2)
|
||||
require.NotEmpty(t, first.NextPageToken)
|
||||
|
||||
// Walking every page must yield each user exactly once (no overlap, no gaps).
|
||||
all := drainListUsers(adminCtx, t, ts, 2)
|
||||
seen := map[string]struct{}{}
|
||||
for _, u := range all {
|
||||
_, dup := seen[u.Username]
|
||||
require.False(t, dup, "user %s returned on multiple pages", u.Username)
|
||||
seen[u.Username] = struct{}{}
|
||||
}
|
||||
require.Equal(t, expected, seen)
|
||||
}
|
||||
|
||||
func TestListUsersDefaultPageSizeReturnsAllWhenSmall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := ts.CreateRegularUser(ctx, fmt.Sprintf("member-%d", i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// With no page size or token the default (50) exceeds the 4 users, so a
|
||||
// single page returns everyone and omits the next page token. This is the
|
||||
// path the admin members UI relies on.
|
||||
resp, err := ts.Service.ListUsers(adminCtx, &apiv1.ListUsersRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Users, 4)
|
||||
require.Empty(t, resp.NextPageToken)
|
||||
}
|
||||
|
||||
func TestListUsersInvalidPageToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
_, err = ts.Service.ListUsers(adminCtx, &apiv1.ListUsersRequest{PageToken: "not-a-valid-token"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid page token")
|
||||
}
|
||||
@@ -76,16 +76,39 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
|
||||
}
|
||||
}
|
||||
|
||||
var limit, offset int
|
||||
if request.PageToken != "" {
|
||||
var pageToken v1pb.PageToken
|
||||
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
||||
}
|
||||
limit = normalizePageSize(pageToken.Limit)
|
||||
offset = max(int(pageToken.Offset), 0)
|
||||
} else {
|
||||
limit = normalizePageSize(request.PageSize)
|
||||
}
|
||||
// Fetch one extra row to detect whether a subsequent page exists.
|
||||
limitPlusOne := limit + 1
|
||||
userFind.Limit = &limitPlusOne
|
||||
userFind.Offset = &offset
|
||||
|
||||
users, err := s.Store.ListUsers(ctx, userFind)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||
}
|
||||
|
||||
// TODO: Implement proper ordering, and pagination
|
||||
// For now, return all users with basic structure
|
||||
nextPageToken := ""
|
||||
if len(users) == limitPlusOne {
|
||||
users = users[:limit]
|
||||
nextPageToken, err = getPageToken(limit, offset+limit)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get next page token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
response := &v1pb.ListUsersResponse{
|
||||
Users: []*v1pb.User{},
|
||||
TotalSize: int32(len(users)),
|
||||
Users: make([]*v1pb.User, 0, len(users)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, user := range users {
|
||||
response.Users = append(response.Users, convertUserFromStore(user, currentUser))
|
||||
|
||||
@@ -220,8 +220,7 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
|
||||
settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...)
|
||||
}
|
||||
response := &v1pb.ListUserSettingsResponse{
|
||||
Settings: settings,
|
||||
TotalSize: int32(len(settings)),
|
||||
Settings: settings,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
|
||||
@@ -83,7 +83,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
|
||||
|
||||
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
orderBy := []string{"`created_ts` DESC", "`row_status` DESC"}
|
||||
orderBy := []string{"`created_ts` DESC", "`row_status` DESC", "`id` DESC"}
|
||||
|
||||
if len(find.Filters) > 0 {
|
||||
return nil, errors.Errorf("user filters are not supported")
|
||||
@@ -147,6 +147,9 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
|
||||
query := "SELECT `id`, `username`, `role`, `email`, `nickname`, `password_hash`, `avatar_url`, `description`, UNIX_TIMESTAMP(`created_ts`), UNIX_TIMESTAMP(`updated_ts`), `row_status` FROM `user` WHERE " + strings.Join(where, " AND ") + " ORDER BY " + strings.Join(orderBy, ", ")
|
||||
if v := find.Limit; v != nil {
|
||||
query += fmt.Sprintf(" LIMIT %d", *v)
|
||||
if v := find.Offset; v != nil {
|
||||
query += fmt.Sprintf(" OFFSET %d", *v)
|
||||
}
|
||||
}
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -86,7 +86,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
|
||||
|
||||
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
orderBy := []string{"created_ts DESC", "row_status DESC"}
|
||||
orderBy := []string{"created_ts DESC", "row_status DESC", "id DESC"}
|
||||
|
||||
if len(find.Filters) > 0 {
|
||||
return nil, errors.Errorf("user filters are not supported")
|
||||
@@ -156,6 +156,9 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
|
||||
WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ` + strings.Join(orderBy, ", ")
|
||||
if v := find.Limit; v != nil {
|
||||
query += fmt.Sprintf(" LIMIT %d", *v)
|
||||
if v := find.Offset; v != nil {
|
||||
query += fmt.Sprintf(" OFFSET %d", *v)
|
||||
}
|
||||
}
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,7 +87,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
|
||||
|
||||
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
orderBy := []string{"created_ts DESC", "row_status DESC"}
|
||||
orderBy := []string{"created_ts DESC", "row_status DESC", "id DESC"}
|
||||
|
||||
if len(find.Filters) > 0 {
|
||||
return nil, errors.Errorf("user filters are not supported")
|
||||
@@ -165,6 +165,9 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
|
||||
WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ` + strings.Join(orderBy, ", ")
|
||||
if v := find.Limit; v != nil {
|
||||
query += fmt.Sprintf(" LIMIT %d", *v)
|
||||
if v := find.Offset; v != nil {
|
||||
query += fmt.Sprintf(" OFFSET %d", *v)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
|
||||
@@ -77,6 +77,8 @@ type FindUser struct {
|
||||
|
||||
// The maximum number of users to return.
|
||||
Limit *int
|
||||
// The offset of the returned users.
|
||||
Offset *int
|
||||
}
|
||||
|
||||
type DeleteUser struct {
|
||||
|
||||
@@ -205,12 +205,18 @@ export function useUpdateUserSetting() {
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to list all users
|
||||
// Hook to list all users, paging through every result.
|
||||
export function useListUsers() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.all,
|
||||
queryFn: async () => {
|
||||
const { users } = await userServiceClient.listUsers({});
|
||||
const users: User[] = [];
|
||||
let pageToken = "";
|
||||
do {
|
||||
const response = await userServiceClient.listUsers({ pageToken });
|
||||
users.push(...response.users);
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
return users;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf";
|
||||
* Describes the file api/v1/attachment_service.proto.
|
||||
*/
|
||||
export const file_api_v1_attachment_service: GenFile = /*@__PURE__*/
|
||||
fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEivAEKC01vdGlvbk1lZGlhEi8KBmZhbWlseRgBIAEoDjIfLm1lbW9zLmFwaS52MS5Nb3Rpb25NZWRpYUZhbWlseRIrCgRyb2xlGAIgASgOMh0ubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhUm9sZRIQCghncm91cF9pZBgDIAEoCRIhChlwcmVzZW50YXRpb25fdGltZXN0YW1wX3VzGAQgASgDEhoKEmhhc19lbWJlZGRlZF92aWRlbxgFIAEoCCLsAgoKQXR0YWNobWVudBIRCgRuYW1lGAEgASgJQgPgQQgSNAoLY3JlYXRlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSFQoIZmlsZW5hbWUYAyABKAlCA+BBAhIUCgdjb250ZW50GAQgASgMQgPgQQQSGgoNZXh0ZXJuYWxfbGluaxgFIAEoCUID4EEBEhEKBHR5cGUYBiABKAlCA+BBAhIRCgRzaXplGAcgASgDQgPgQQMSFgoEbWVtbxgIIAEoCUID4EEBSACIAQESNAoMbW90aW9uX21lZGlhGAkgASgLMhkubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhQgPgQQE6T+pBTAoXbWVtb3MuYXBpLnYxL0F0dGFjaG1lbnQSGGF0dGFjaG1lbnRzL3thdHRhY2htZW50fSoLYXR0YWNobWVudHMyCmF0dGFjaG1lbnRCBwoFX21lbW8iaAoXQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISGgoNYXR0YWNobWVudF9pZBgCIAEoCUID4EEBInUKFkxpc3RBdHRhY2htZW50c1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEhMKBmZpbHRlchgDIAEoCUID4EEBEhUKCG9yZGVyX2J5GAQgASgJQgPgQQEidQoXTGlzdEF0dGFjaG1lbnRzUmVzcG9uc2USLQoLYXR0YWNobWVudHMYASADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSJFChRHZXRBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IoIBChdVcGRhdGVBdHRhY2htZW50UmVxdWVzdBIxCgphdHRhY2htZW50GAEgASgLMhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJIChdEZWxldGVBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IjMKHUJhdGNoRGVsZXRlQXR0YWNobWVudHNSZXF1ZXN0EhIKBW5hbWVzGAEgAygJQgPgQQIqaAoRTW90aW9uTWVkaWFGYW1pbHkSIwofTU9USU9OX01FRElBX0ZBTUlMWV9VTlNQRUNJRklFRBAAEhQKEEFQUExFX0xJVkVfUEhPVE8QARIYChRBTkRST0lEX01PVElPTl9QSE9UTxACKlkKD01vdGlvbk1lZGlhUm9sZRIhCh1NT1RJT05fTUVESUFfUk9MRV9VTlNQRUNJRklFRBAAEgkKBVNUSUxMEAESCQoFVklERU8QAhINCglDT05UQUlORVIQAzLQBgoRQXR0YWNobWVudFNlcnZpY2USiQEKEENyZWF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCI02kEKYXR0YWNobWVudILT5JMCIToKYXR0YWNobWVudCITL2FwaS92MS9hdHRhY2htZW50cxJ7Cg9MaXN0QXR0YWNobWVudHMSJC5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVxdWVzdBolLm1lbW9zLmFwaS52MS5MaXN0QXR0YWNobWVudHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL2F0dGFjaG1lbnRzEnoKDUdldEF0dGFjaG1lbnQSIi5tZW1vcy5hcGkudjEuR2V0QXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCIr2kEEbmFtZYLT5JMCHhIcL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKpAQoQVXBkYXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5VcGRhdGVBdHRhY2htZW50UmVxdWVzdBoYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50IlTaQRZhdHRhY2htZW50LHVwZGF0ZV9tYXNrgtPkkwI1OgphdHRhY2htZW50MicvYXBpL3YxL3thdHRhY2htZW50Lm5hbWU9YXR0YWNobWVudHMvKn0SfgoQRGVsZXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5EZWxldGVBdHRhY2htZW50UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIr2kEEbmFtZYLT5JMCHiocL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKJAQoWQmF0Y2hEZWxldGVBdHRhY2htZW50cxIrLm1lbW9zLmFwaS52MS5CYXRjaERlbGV0ZUF0dGFjaG1lbnRzUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIqgtPkkwIkOgEqIh8vYXBpL3YxL2F0dGFjaG1lbnRzOmJhdGNoRGVsZXRlQq4BChBjb20ubWVtb3MuYXBpLnYxQhZBdHRhY2htZW50U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
|
||||
fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEivAEKC01vdGlvbk1lZGlhEi8KBmZhbWlseRgBIAEoDjIfLm1lbW9zLmFwaS52MS5Nb3Rpb25NZWRpYUZhbWlseRIrCgRyb2xlGAIgASgOMh0ubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhUm9sZRIQCghncm91cF9pZBgDIAEoCRIhChlwcmVzZW50YXRpb25fdGltZXN0YW1wX3VzGAQgASgDEhoKEmhhc19lbWJlZGRlZF92aWRlbxgFIAEoCCLsAgoKQXR0YWNobWVudBIRCgRuYW1lGAEgASgJQgPgQQgSNAoLY3JlYXRlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSFQoIZmlsZW5hbWUYAyABKAlCA+BBAhIUCgdjb250ZW50GAQgASgMQgPgQQQSGgoNZXh0ZXJuYWxfbGluaxgFIAEoCUID4EEBEhEKBHR5cGUYBiABKAlCA+BBAhIRCgRzaXplGAcgASgDQgPgQQMSFgoEbWVtbxgIIAEoCUID4EEBSACIAQESNAoMbW90aW9uX21lZGlhGAkgASgLMhkubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhQgPgQQE6T+pBTAoXbWVtb3MuYXBpLnYxL0F0dGFjaG1lbnQSGGF0dGFjaG1lbnRzL3thdHRhY2htZW50fSoLYXR0YWNobWVudHMyCmF0dGFjaG1lbnRCBwoFX21lbW8iaAoXQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISGgoNYXR0YWNobWVudF9pZBgCIAEoCUID4EEBInUKFkxpc3RBdHRhY2htZW50c1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEhMKBmZpbHRlchgDIAEoCUID4EEBEhUKCG9yZGVyX2J5GAQgASgJQgPgQQEiYQoXTGlzdEF0dGFjaG1lbnRzUmVzcG9uc2USLQoLYXR0YWNobWVudHMYASADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkiRQoUR2V0QXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudCKCAQoXVXBkYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQIiSAoXRGVsZXRlQXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudCIzCh1CYXRjaERlbGV0ZUF0dGFjaG1lbnRzUmVxdWVzdBISCgVuYW1lcxgBIAMoCUID4EECKmgKEU1vdGlvbk1lZGlhRmFtaWx5EiMKH01PVElPTl9NRURJQV9GQU1JTFlfVU5TUEVDSUZJRUQQABIUChBBUFBMRV9MSVZFX1BIT1RPEAESGAoUQU5EUk9JRF9NT1RJT05fUEhPVE8QAipZCg9Nb3Rpb25NZWRpYVJvbGUSIQodTU9USU9OX01FRElBX1JPTEVfVU5TUEVDSUZJRUQQABIJCgVTVElMTBABEgkKBVZJREVPEAISDQoJQ09OVEFJTkVSEAMy0AYKEUF0dGFjaG1lbnRTZXJ2aWNlEokBChBDcmVhdGVBdHRhY2htZW50EiUubWVtb3MuYXBpLnYxLkNyZWF0ZUF0dGFjaG1lbnRSZXF1ZXN0GhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnQiNNpBCmF0dGFjaG1lbnSC0+STAiE6CmF0dGFjaG1lbnQiEy9hcGkvdjEvYXR0YWNobWVudHMSewoPTGlzdEF0dGFjaG1lbnRzEiQubWVtb3MuYXBpLnYxLkxpc3RBdHRhY2htZW50c1JlcXVlc3QaJS5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVzcG9uc2UiG4LT5JMCFRITL2FwaS92MS9hdHRhY2htZW50cxJ6Cg1HZXRBdHRhY2htZW50EiIubWVtb3MuYXBpLnYxLkdldEF0dGFjaG1lbnRSZXF1ZXN0GhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnQiK9pBBG5hbWWC0+STAh4SHC9hcGkvdjEve25hbWU9YXR0YWNobWVudHMvKn0SqQEKEFVwZGF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuVXBkYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCJU2kEWYXR0YWNobWVudCx1cGRhdGVfbWFza4LT5JMCNToKYXR0YWNobWVudDInL2FwaS92MS97YXR0YWNobWVudC5uYW1lPWF0dGFjaG1lbnRzLyp9En4KEERlbGV0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuRGVsZXRlQXR0YWNobWVudFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiK9pBBG5hbWWC0+STAh4qHC9hcGkvdjEve25hbWU9YXR0YWNobWVudHMvKn0SiQEKFkJhdGNoRGVsZXRlQXR0YWNobWVudHMSKy5tZW1vcy5hcGkudjEuQmF0Y2hEZWxldGVBdHRhY2htZW50c1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiKoLT5JMCJDoBKiIfL2FwaS92MS9hdHRhY2htZW50czpiYXRjaERlbGV0ZUKuAQoQY29tLm1lbW9zLmFwaS52MUIWQXR0YWNobWVudFNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
|
||||
|
||||
/**
|
||||
* @generated from message memos.api.v1.MotionMedia
|
||||
@@ -225,13 +225,6 @@ export type ListAttachmentsResponse = Message<"memos.api.v1.ListAttachmentsRespo
|
||||
* @generated from field: string next_page_token = 2;
|
||||
*/
|
||||
nextPageToken: string;
|
||||
|
||||
/**
|
||||
* The total count of attachments (may be approximate).
|
||||
*
|
||||
* @generated from field: int32 total_size = 3;
|
||||
*/
|
||||
totalSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user