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:
boojack
2026-07-12 20:51:04 +08:00
parent 375e2bc60e
commit 4bc3928029
20 changed files with 170 additions and 189 deletions
-3
View File
@@ -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 {
-6
View File
@@ -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 {
-9
View File
@@ -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 {
+2 -13
View File
@@ -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" +
+4 -26
View File
@@ -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" +
+6 -39
View File
@@ -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" +
-24
View File
@@ -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:
+1 -12
View File
@@ -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)
+1 -1
View File
@@ -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")
}
+27 -4
View File
@@ -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
+4 -1
View File
@@ -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 {
+4 -1
View File
@@ -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 {
+4 -1
View File
@@ -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...)
+2
View File
@@ -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 {
+8 -2
View File
@@ -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