Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
50638040
Unverified
Commit
50638040
authored
Apr 22, 2026
by
boojack
Committed by
GitHub
Apr 22, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: reduce list memo query overhead (#5880)
parent
ebc0e10f
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
453 additions
and
43 deletions
+453
-43
memo_service.go
server/router/api/v1/memo_service.go
+18
-6
memo_service_converter.go
server/router/api/v1/memo_service_converter.go
+69
-18
memo_share_service.go
server/router/api/v1/memo_share_service.go
+1
-1
memo_service_benchmark_test.go
server/router/api/v1/test/memo_service_benchmark_test.go
+282
-0
memo_relation.go
store/db/mysql/memo_relation.go
+16
-0
memo_relation.go
store/db/postgres/memo_relation.go
+20
-4
memo_relation.go
store/db/sqlite/memo_relation.go
+16
-1
memo_relation.go
store/memo_relation.go
+6
-2
RelationListView.tsx
...src/components/MemoMetadata/Relation/RelationListView.tsx
+11
-3
useResolvedRelationMemos.ts
...ponents/MemoMetadata/Relation/useResolvedRelationMemos.ts
+4
-3
MemoCommentListView.tsx
...rc/components/MemoView/components/MemoCommentListView.tsx
+1
-1
useMemoQueries.ts
web/src/hooks/useMemoQueries.ts
+9
-4
No files found.
server/router/api/v1/memo_service.go
View file @
50638040
...
...
@@ -297,18 +297,22 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
}
// RELATIONS (batch load to avoid N+1)
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
memos
)
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
memos
,
false
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to batch load memo relations"
)
}
creatorIDs
:=
make
([]
int32
,
0
,
len
(
memos
))
creatorIDs
:=
make
([]
int32
,
0
,
len
(
memos
)
+
len
(
reactions
)
)
for
_
,
memo
:=
range
memos
{
creatorIDs
=
append
(
creatorIDs
,
memo
.
CreatorID
)
}
for
_
,
reaction
:=
range
reactions
{
creatorIDs
=
append
(
creatorIDs
,
reaction
.
CreatorID
)
}
creatorMap
,
err
:=
s
.
listUsersByID
(
ctx
,
creatorIDs
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to list memo creators: %v"
,
err
)
}
conversionOptions
:=
memoConversionOptions
{
displayWithUpdateTime
:
instanceMemoRelatedSetting
.
DisplayWithUpdateTime
}
for
_
,
memo
:=
range
memos
{
memoName
:=
fmt
.
Sprintf
(
"%s%s"
,
MemoNamePrefix
,
memo
.
UID
)
...
...
@@ -316,7 +320,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
attachments
:=
attachmentMap
[
memo
.
ID
]
relations
:=
relationMap
[
memo
.
ID
]
memoMessage
,
err
:=
s
.
convertMemoFromStoreWithCreators
(
ctx
,
memo
,
reactions
,
attachments
,
relations
,
creatorMap
)
memoMessage
,
err
:=
s
.
convertMemoFromStoreWithCreators
AndOptions
(
ctx
,
memo
,
reactions
,
attachments
,
relations
,
creatorMap
,
conversionOptions
)
if
err
!=
nil
{
if
stderrors
.
Is
(
err
,
errMemoCreatorNotFound
)
{
slog
.
Warn
(
"Skipping memo with missing creator"
,
...
...
@@ -798,18 +802,26 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
}
// RELATIONS (batch load to avoid N+1)
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
memos
)
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
memos
,
false
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to batch load memo relations"
)
}
creatorIDs
:=
make
([]
int32
,
0
,
len
(
memos
))
creatorIDs
:=
make
([]
int32
,
0
,
len
(
memos
)
+
len
(
reactions
)
)
for
_
,
memo
:=
range
memos
{
creatorIDs
=
append
(
creatorIDs
,
memo
.
CreatorID
)
}
for
_
,
reaction
:=
range
reactions
{
creatorIDs
=
append
(
creatorIDs
,
reaction
.
CreatorID
)
}
creatorMap
,
err
:=
s
.
listUsersByID
(
ctx
,
creatorIDs
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to list memo creators: %v"
,
err
)
}
instanceMemoRelatedSetting
,
err
:=
s
.
Store
.
GetInstanceMemoRelatedSetting
(
ctx
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to get instance memo related setting"
)
}
conversionOptions
:=
memoConversionOptions
{
displayWithUpdateTime
:
instanceMemoRelatedSetting
.
DisplayWithUpdateTime
}
var
memosResponse
[]
*
v1pb
.
Memo
for
_
,
m
:=
range
memos
{
...
...
@@ -818,7 +830,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
attachments
:=
attachmentMap
[
m
.
ID
]
relations
:=
relationMap
[
m
.
ID
]
memoMessage
,
err
:=
s
.
convertMemoFromStoreWithCreators
(
ctx
,
m
,
reactions
,
attachments
,
relations
,
creatorMap
)
memoMessage
,
err
:=
s
.
convertMemoFromStoreWithCreators
AndOptions
(
ctx
,
m
,
reactions
,
attachments
,
relations
,
creatorMap
,
conversionOptions
)
if
err
!=
nil
{
if
stderrors
.
Is
(
err
,
errMemoCreatorNotFound
)
{
slog
.
Warn
(
"Skipping memo comment with missing creator"
,
...
...
server/router/api/v1/memo_service_converter.go
View file @
50638040
...
...
@@ -20,6 +20,10 @@ var (
errReactionCreatorNotFound
=
stderrors
.
New
(
"reaction creator not found"
)
)
type
memoConversionOptions
struct
{
displayWithUpdateTime
bool
}
func
(
s
*
APIV1Service
)
convertMemoFromStore
(
ctx
context
.
Context
,
memo
*
store
.
Memo
,
reactions
[]
*
store
.
Reaction
,
attachments
[]
*
store
.
Attachment
,
relations
[]
*
v1pb
.
MemoRelation
)
(
*
v1pb
.
Memo
,
error
)
{
creatorMap
,
err
:=
s
.
listUsersByID
(
ctx
,
[]
int32
{
memo
.
CreatorID
})
if
err
!=
nil
{
...
...
@@ -29,12 +33,24 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
}
func
(
s
*
APIV1Service
)
convertMemoFromStoreWithCreators
(
ctx
context
.
Context
,
memo
*
store
.
Memo
,
reactions
[]
*
store
.
Reaction
,
attachments
[]
*
store
.
Attachment
,
relations
[]
*
v1pb
.
MemoRelation
,
creatorMap
map
[
int32
]
*
store
.
User
)
(
*
v1pb
.
Memo
,
error
)
{
displayTs
:=
memo
.
CreatedTs
instanceMemoRelatedSetting
,
err
:=
s
.
Store
.
GetInstanceMemoRelatedSetting
(
ctx
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to get instance memo related setting"
)
}
if
instanceMemoRelatedSetting
.
DisplayWithUpdateTime
{
return
s
.
convertMemoFromStoreWithCreatorsAndOptions
(
ctx
,
memo
,
reactions
,
attachments
,
relations
,
creatorMap
,
memoConversionOptions
{
displayWithUpdateTime
:
instanceMemoRelatedSetting
.
DisplayWithUpdateTime
},
)
}
func
(
s
*
APIV1Service
)
convertMemoFromStoreWithCreatorsAndOptions
(
ctx
context
.
Context
,
memo
*
store
.
Memo
,
reactions
[]
*
store
.
Reaction
,
attachments
[]
*
store
.
Attachment
,
relations
[]
*
v1pb
.
MemoRelation
,
creatorMap
map
[
int32
]
*
store
.
User
,
options
memoConversionOptions
)
(
*
v1pb
.
Memo
,
error
)
{
displayTs
:=
memo
.
CreatedTs
if
options
.
displayWithUpdateTime
{
displayTs
=
memo
.
UpdatedTs
}
...
...
@@ -65,10 +81,11 @@ func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, mem
memoMessage
.
Parent
=
&
parentName
}
memoMessage
.
Reactions
,
err
=
s
.
convertReactionsFromStoreWithCreators
(
ctx
,
reactions
,
creatorMap
)
reactionMessages
,
err
:
=
s
.
convertReactionsFromStoreWithCreators
(
ctx
,
reactions
,
creatorMap
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to convert reactions"
)
}
memoMessage
.
Reactions
=
reactionMessages
if
relations
!=
nil
{
memoMessage
.
Relations
=
relations
...
...
@@ -179,7 +196,7 @@ func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID
// batchConvertMemoRelations batch-loads relations for a list of memos and returns
// a map from memo ID to its converted relations. This avoids N+1 queries when listing memos.
func
(
s
*
APIV1Service
)
batchConvertMemoRelations
(
ctx
context
.
Context
,
memos
[]
*
store
.
Memo
)
(
map
[
int32
][]
*
v1pb
.
MemoRelation
,
error
)
{
func
(
s
*
APIV1Service
)
batchConvertMemoRelations
(
ctx
context
.
Context
,
memos
[]
*
store
.
Memo
,
includeSnippets
bool
)
(
map
[
int32
][]
*
v1pb
.
MemoRelation
,
error
)
{
if
len
(
memos
)
==
0
{
return
map
[
int32
][]
*
v1pb
.
MemoRelation
{},
nil
}
...
...
@@ -202,14 +219,21 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
memoIDSet
[
m
.
ID
]
=
true
}
// Single batch query to get all relations involving any of these memos.
allRelations
,
err
:=
s
.
Store
.
ListMemoRelations
(
ctx
,
&
store
.
FindMemoRelation
{
MemoIDList
:
memoIDs
,
outgoingRelations
,
err
:=
s
.
Store
.
ListMemoRelations
(
ctx
,
&
store
.
FindMemoRelation
{
SourceMemoIDList
:
memoIDs
,
MemoFilter
:
&
memoFilter
,
})
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to batch list outgoing memo relations"
)
}
incomingRelations
,
err
:=
s
.
Store
.
ListMemoRelations
(
ctx
,
&
store
.
FindMemoRelation
{
RelatedMemoIDList
:
memoIDs
,
MemoFilter
:
&
memoFilter
,
})
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to batch list memo relations"
)
return
nil
,
errors
.
Wrap
(
err
,
"failed to batch list
incoming
memo relations"
)
}
allRelations
:=
mergeMemoRelations
(
outgoingRelations
,
incomingRelations
)
// Collect all memo IDs referenced in relations that we need to resolve.
neededIDs
:=
make
(
map
[
int32
]
bool
)
...
...
@@ -220,10 +244,16 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
// Build ID→UID map from the memos we already have.
memoIDToUID
:=
make
(
map
[
int32
]
string
,
len
(
memos
))
memoIDTo
Conten
t
:=
make
(
map
[
int32
]
string
,
len
(
memos
))
memoIDTo
Snippe
t
:=
make
(
map
[
int32
]
string
,
len
(
memos
))
for
_
,
m
:=
range
memos
{
memoIDToUID
[
m
.
ID
]
=
m
.
UID
memoIDToContent
[
m
.
ID
]
=
m
.
Content
if
includeSnippets
{
snippet
,
err
:=
s
.
getMemoContentSnippet
(
m
.
Content
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to get memo content snippet"
)
}
memoIDToSnippet
[
m
.
ID
]
=
snippet
}
delete
(
neededIDs
,
m
.
ID
)
}
...
...
@@ -233,13 +263,20 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
for
id
:=
range
neededIDs
{
extraIDs
=
append
(
extraIDs
,
id
)
}
extraMemos
,
err
:=
s
.
Store
.
ListMemos
(
ctx
,
&
store
.
FindMemo
{
IDList
:
extraIDs
})
extraFind
:=
&
store
.
FindMemo
{
IDList
:
extraIDs
,
ExcludeContent
:
!
includeSnippets
}
extraMemos
,
err
:=
s
.
Store
.
ListMemos
(
ctx
,
extraFind
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to batch fetch related memos"
)
}
for
_
,
m
:=
range
extraMemos
{
memoIDToUID
[
m
.
ID
]
=
m
.
UID
memoIDToContent
[
m
.
ID
]
=
m
.
Content
if
includeSnippets
{
snippet
,
err
:=
s
.
getMemoContentSnippet
(
m
.
Content
)
if
err
!=
nil
{
return
nil
,
errors
.
Wrap
(
err
,
"failed to get related memo content snippet"
)
}
memoIDToSnippet
[
m
.
ID
]
=
snippet
}
}
}
...
...
@@ -252,16 +289,14 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
continue
}
memoSnippet
,
_
:=
s
.
getMemoContentSnippet
(
memoIDToContent
[
r
.
MemoID
])
relatedSnippet
,
_
:=
s
.
getMemoContentSnippet
(
memoIDToContent
[
r
.
RelatedMemoID
])
relation
:=
&
v1pb
.
MemoRelation
{
Memo
:
&
v1pb
.
MemoRelation_Memo
{
Name
:
fmt
.
Sprintf
(
"%s%s"
,
MemoNamePrefix
,
memoUID
),
Snippet
:
memo
Snippet
,
Snippet
:
memo
IDToSnippet
[
r
.
MemoID
]
,
},
RelatedMemo
:
&
v1pb
.
MemoRelation_Memo
{
Name
:
fmt
.
Sprintf
(
"%s%s"
,
MemoNamePrefix
,
relatedUID
),
Snippet
:
relatedSnippet
,
Snippet
:
memoIDToSnippet
[
r
.
RelatedMemoID
]
,
},
Type
:
convertMemoRelationTypeFromStore
(
r
.
Type
),
}
...
...
@@ -280,13 +315,29 @@ func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*s
// loadMemoRelations loads relations for a single memo and converts them to API format.
func
(
s
*
APIV1Service
)
loadMemoRelations
(
ctx
context
.
Context
,
memo
*
store
.
Memo
)
([]
*
v1pb
.
MemoRelation
,
error
)
{
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
[]
*
store
.
Memo
{
memo
})
relationMap
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
[]
*
store
.
Memo
{
memo
}
,
true
)
if
err
!=
nil
{
return
nil
,
err
}
return
relationMap
[
memo
.
ID
],
nil
}
func
mergeMemoRelations
(
groups
...
[]
*
store
.
MemoRelation
)
[]
*
store
.
MemoRelation
{
seen
:=
make
(
map
[
string
]
struct
{})
merged
:=
make
([]
*
store
.
MemoRelation
,
0
)
for
_
,
relations
:=
range
groups
{
for
_
,
relation
:=
range
relations
{
key
:=
fmt
.
Sprintf
(
"%d:%d:%s"
,
relation
.
MemoID
,
relation
.
RelatedMemoID
,
relation
.
Type
)
if
_
,
ok
:=
seen
[
key
];
ok
{
continue
}
seen
[
key
]
=
struct
{}{}
merged
=
append
(
merged
,
relation
)
}
}
return
merged
}
func
convertMemoPropertyFromStore
(
property
*
storepb
.
MemoPayload_Property
)
*
v1pb
.
Memo_Property
{
if
property
==
nil
{
return
nil
...
...
server/router/api/v1/memo_share_service.go
View file @
50638040
...
...
@@ -176,7 +176,7 @@ func (s *APIV1Service) GetMemoByShare(ctx context.Context, request *v1pb.GetMemo
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to list attachments"
)
}
relations
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
[]
*
store
.
Memo
{
memo
})
relations
,
err
:=
s
.
batchConvertMemoRelations
(
ctx
,
[]
*
store
.
Memo
{
memo
}
,
true
)
if
err
!=
nil
{
return
nil
,
status
.
Errorf
(
codes
.
Internal
,
"failed to load memo relations"
)
}
...
...
server/router/api/v1/test/memo_service_benchmark_test.go
0 → 100644
View file @
50638040
package
test
import
(
"context"
"fmt"
"path/filepath"
"testing"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/version"
v1pb
"github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/server/auth"
apiv1
"github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
const
(
benchmarkTopLevelMemoCount
=
5000
benchmarkPageSize
=
16
)
type
benchmarkService
struct
{
*
TestService
hostUser
*
store
.
User
authenticatedCtx
context
.
Context
publicCtx
context
.
Context
pageTenToken
string
commentParentName
string
}
func
newBenchmarkService
(
tb
testing
.
TB
)
*
benchmarkService
{
tb
.
Helper
()
ctx
:=
context
.
Background
()
testService
:=
newTestingServiceForTB
(
tb
)
hostUser
,
err
:=
testService
.
CreateHostUser
(
ctx
,
"bench-host"
)
if
err
!=
nil
{
tb
.
Fatalf
(
"failed to create host user: %v"
,
err
)
}
commentParentName
,
err
:=
seedListMemosBenchmarkData
(
ctx
,
testService
.
Store
,
hostUser
)
if
err
!=
nil
{
tb
.
Fatalf
(
"failed to seed benchmark data: %v"
,
err
)
}
authenticatedCtx
:=
context
.
WithValue
(
context
.
Background
(),
auth
.
UserIDContextKey
,
hostUser
.
ID
)
pageTenToken
,
err
:=
getListMemosPageToken
(
authenticatedCtx
,
testService
.
Service
,
10
,
benchmarkPageSize
)
if
err
!=
nil
{
tb
.
Fatalf
(
"failed to build page token: %v"
,
err
)
}
return
&
benchmarkService
{
TestService
:
testService
,
hostUser
:
hostUser
,
authenticatedCtx
:
authenticatedCtx
,
publicCtx
:
context
.
Background
(),
pageTenToken
:
pageTenToken
,
commentParentName
:
commentParentName
,
}
}
func
newTestingServiceForTB
(
tb
testing
.
TB
)
*
TestService
{
tb
.
Helper
()
ctx
:=
context
.
Background
()
dataDir
:=
tb
.
TempDir
()
testProfile
:=
getBenchmarkProfile
(
dataDir
)
dbDriver
,
err
:=
db
.
NewDBDriver
(
testProfile
)
if
err
!=
nil
{
tb
.
Fatalf
(
"failed to create db driver: %v"
,
err
)
}
testStore
:=
store
.
New
(
dbDriver
,
testProfile
)
if
err
:=
testStore
.
Migrate
(
ctx
);
err
!=
nil
{
tb
.
Fatalf
(
"failed to migrate db: %v"
,
err
)
}
tb
.
Cleanup
(
func
()
{
testStore
.
Close
()
})
service
:=
newServiceWithProfile
(
testProfile
,
testStore
)
return
&
TestService
{
Service
:
service
,
Store
:
testStore
,
Profile
:
testProfile
,
Secret
:
service
.
Secret
,
}
}
func
getBenchmarkProfile
(
dataDir
string
)
*
profile
.
Profile
{
return
&
profile
.
Profile
{
Demo
:
true
,
Version
:
version
.
GetCurrentVersion
(),
InstanceURL
:
"http://localhost:8080"
,
Driver
:
"sqlite"
,
DSN
:
filepath
.
Join
(
dataDir
,
"bench.db"
),
Data
:
dataDir
,
}
}
func
newServiceWithProfile
(
testProfile
*
profile
.
Profile
,
testStore
*
store
.
Store
)
*
apiv1
.
APIV1Service
{
service
:=
apiv1
.
NewAPIV1Service
(
"bench-secret"
,
testProfile
,
testStore
)
return
service
}
func
seedListMemosBenchmarkData
(
ctx
context
.
Context
,
stores
*
store
.
Store
,
hostUser
*
store
.
User
)
(
string
,
error
)
{
topLevelMemos
:=
make
([]
*
store
.
Memo
,
0
,
benchmarkTopLevelMemoCount
)
commentParentName
:=
""
for
i
:=
0
;
i
<
benchmarkTopLevelMemoCount
;
i
++
{
visibility
:=
store
.
Private
if
i
%
4
==
0
{
visibility
=
store
.
Public
}
memo
,
err
:=
stores
.
CreateMemo
(
ctx
,
&
store
.
Memo
{
UID
:
fmt
.
Sprintf
(
"memo-%06d"
,
i
),
CreatorID
:
hostUser
.
ID
,
Content
:
benchmarkMemoContent
(
i
),
Visibility
:
visibility
,
})
if
err
!=
nil
{
return
""
,
err
}
topLevelMemos
=
append
(
topLevelMemos
,
memo
)
if
i
%
3
==
0
{
if
_
,
err
:=
stores
.
CreateAttachment
(
ctx
,
&
store
.
Attachment
{
UID
:
fmt
.
Sprintf
(
"att-%06d"
,
i
),
CreatorID
:
hostUser
.
ID
,
Filename
:
fmt
.
Sprintf
(
"memo-%06d.png"
,
i
),
Type
:
"image/png"
,
Size
:
2048
,
MemoID
:
&
memo
.
ID
,
});
err
!=
nil
{
return
""
,
err
}
}
if
i
%
5
==
0
{
if
_
,
err
:=
stores
.
UpsertReaction
(
ctx
,
&
store
.
Reaction
{
CreatorID
:
hostUser
.
ID
,
ContentID
:
"memos/"
+
memo
.
UID
,
ReactionType
:
"thumbs-up"
,
});
err
!=
nil
{
return
""
,
err
}
}
}
for
i
,
memo
:=
range
topLevelMemos
{
if
i
+
1
<
len
(
topLevelMemos
)
&&
i
%
4
==
0
{
if
_
,
err
:=
stores
.
UpsertMemoRelation
(
ctx
,
&
store
.
MemoRelation
{
MemoID
:
memo
.
ID
,
RelatedMemoID
:
topLevelMemos
[
i
+
1
]
.
ID
,
Type
:
store
.
MemoRelationReference
,
});
err
!=
nil
{
return
""
,
err
}
}
if
i
%
6
==
0
{
commentMemo
,
err
:=
stores
.
CreateMemo
(
ctx
,
&
store
.
Memo
{
UID
:
fmt
.
Sprintf
(
"comment-%06d"
,
i
),
CreatorID
:
hostUser
.
ID
,
Content
:
fmt
.
Sprintf
(
"Comment for memo %06d"
,
i
),
Visibility
:
store
.
Private
,
})
if
err
!=
nil
{
return
""
,
err
}
if
_
,
err
:=
stores
.
UpsertMemoRelation
(
ctx
,
&
store
.
MemoRelation
{
MemoID
:
commentMemo
.
ID
,
RelatedMemoID
:
memo
.
ID
,
Type
:
store
.
MemoRelationComment
,
});
err
!=
nil
{
return
""
,
err
}
if
commentParentName
==
""
{
commentParentName
=
"memos/"
+
memo
.
UID
}
}
}
return
commentParentName
,
nil
}
func
benchmarkMemoContent
(
i
int
)
string
{
return
fmt
.
Sprintf
(
"# Bench Memo %06d
\n\n
This is benchmark memo %06d with enough content to exercise snippet generation.
\n\n
- task one
\n
- task two
\n
"
,
i
,
i
)
}
func
getListMemosPageToken
(
ctx
context
.
Context
,
service
*
apiv1
.
APIV1Service
,
page
int
,
pageSize
int32
)
(
string
,
error
)
{
pageToken
:=
""
for
range
page
-
1
{
resp
,
err
:=
service
.
ListMemos
(
ctx
,
&
v1pb
.
ListMemosRequest
{
PageSize
:
pageSize
,
PageToken
:
pageToken
,
})
if
err
!=
nil
{
return
""
,
err
}
pageToken
=
resp
.
NextPageToken
if
pageToken
==
""
{
break
}
}
return
pageToken
,
nil
}
func
BenchmarkListMemos
(
b
*
testing
.
B
)
{
bench
:=
newBenchmarkService
(
b
)
b
.
Run
(
"authenticated_first_page"
,
func
(
b
*
testing
.
B
)
{
req
:=
&
v1pb
.
ListMemosRequest
{
PageSize
:
benchmarkPageSize
}
b
.
ReportAllocs
()
b
.
ResetTimer
()
for
i
:=
0
;
i
<
b
.
N
;
i
++
{
resp
,
err
:=
bench
.
Service
.
ListMemos
(
bench
.
authenticatedCtx
,
req
)
if
err
!=
nil
{
b
.
Fatalf
(
"ListMemos failed: %v"
,
err
)
}
if
len
(
resp
.
Memos
)
==
0
{
b
.
Fatal
(
"expected memos in authenticated benchmark response"
)
}
}
})
b
.
Run
(
"authenticated_page_ten"
,
func
(
b
*
testing
.
B
)
{
req
:=
&
v1pb
.
ListMemosRequest
{
PageSize
:
benchmarkPageSize
,
PageToken
:
bench
.
pageTenToken
}
b
.
ReportAllocs
()
b
.
ResetTimer
()
for
i
:=
0
;
i
<
b
.
N
;
i
++
{
resp
,
err
:=
bench
.
Service
.
ListMemos
(
bench
.
authenticatedCtx
,
req
)
if
err
!=
nil
{
b
.
Fatalf
(
"ListMemos failed: %v"
,
err
)
}
if
len
(
resp
.
Memos
)
==
0
{
b
.
Fatal
(
"expected memos in paged benchmark response"
)
}
}
})
b
.
Run
(
"public_first_page"
,
func
(
b
*
testing
.
B
)
{
req
:=
&
v1pb
.
ListMemosRequest
{
PageSize
:
benchmarkPageSize
}
b
.
ReportAllocs
()
b
.
ResetTimer
()
for
i
:=
0
;
i
<
b
.
N
;
i
++
{
resp
,
err
:=
bench
.
Service
.
ListMemos
(
bench
.
publicCtx
,
req
)
if
err
!=
nil
{
b
.
Fatalf
(
"ListMemos failed: %v"
,
err
)
}
if
len
(
resp
.
Memos
)
==
0
{
b
.
Fatal
(
"expected memos in public benchmark response"
)
}
}
})
}
func
BenchmarkListMemoCommentsPreview
(
b
*
testing
.
B
)
{
bench
:=
newBenchmarkService
(
b
)
if
bench
.
commentParentName
==
""
{
b
.
Fatal
(
"expected seeded memo with comments"
)
}
req
:=
&
v1pb
.
ListMemoCommentsRequest
{
Name
:
bench
.
commentParentName
,
PageSize
:
3
,
}
b
.
ReportAllocs
()
b
.
ResetTimer
()
for
i
:=
0
;
i
<
b
.
N
;
i
++
{
resp
,
err
:=
bench
.
Service
.
ListMemoComments
(
bench
.
authenticatedCtx
,
req
)
if
err
!=
nil
{
b
.
Fatalf
(
"ListMemoComments failed: %v"
,
err
)
}
if
len
(
resp
.
Memos
)
==
0
{
b
.
Fatal
(
"expected comments in benchmark response"
)
}
}
}
store/db/mysql/memo_relation.go
View file @
50638040
...
...
@@ -54,6 +54,22 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"(`memo_id` IN (%s) OR `related_memo_id` IN (%s))"
,
inClause
,
inClause
))
}
if
len
(
find
.
SourceMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
SourceMemoIDList
))
for
i
,
id
:=
range
find
.
SourceMemoIDList
{
placeholders
[
i
]
=
"?"
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"`memo_id` IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
len
(
find
.
RelatedMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
RelatedMemoIDList
))
for
i
,
id
:=
range
find
.
RelatedMemoIDList
{
placeholders
[
i
]
=
"?"
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"`related_memo_id` IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
find
.
MemoFilter
!=
nil
{
engine
,
err
:=
filter
.
DefaultEngine
()
if
err
!=
nil
{
...
...
store/db/postgres/memo_relation.go
View file @
50638040
...
...
@@ -50,18 +50,34 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
where
,
args
=
append
(
where
,
"type = "
+
placeholder
(
len
(
args
)
+
1
)),
append
(
args
,
*
find
.
Type
)
}
if
len
(
find
.
MemoIDList
)
>
0
{
memoP
laceholders
:=
make
([]
string
,
len
(
find
.
MemoIDList
))
p
laceholders
:=
make
([]
string
,
len
(
find
.
MemoIDList
))
for
i
,
id
:=
range
find
.
MemoIDList
{
memoP
laceholders
[
i
]
=
placeholder
(
len
(
args
)
+
1
)
p
laceholders
[
i
]
=
placeholder
(
len
(
args
)
+
1
)
args
=
append
(
args
,
id
)
}
inClause
:=
strings
.
Join
(
placeholders
,
", "
)
relatedPlaceholders
:=
make
([]
string
,
len
(
find
.
MemoIDList
))
for
i
,
id
:=
range
find
.
MemoIDList
{
relatedPlaceholders
[
i
]
=
placeholder
(
len
(
args
)
+
1
)
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"(memo_id IN (%s) OR related_memo_id IN (%s))"
,
strings
.
Join
(
memoPlaceholders
,
", "
),
strings
.
Join
(
relatedPlaceholders
,
", "
)))
where
=
append
(
where
,
fmt
.
Sprintf
(
"(memo_id IN (%s) OR related_memo_id IN (%s))"
,
inClause
,
strings
.
Join
(
relatedPlaceholders
,
", "
)))
}
if
len
(
find
.
SourceMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
SourceMemoIDList
))
for
i
,
id
:=
range
find
.
SourceMemoIDList
{
placeholders
[
i
]
=
placeholder
(
len
(
args
)
+
1
)
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"memo_id IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
len
(
find
.
RelatedMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
RelatedMemoIDList
))
for
i
,
id
:=
range
find
.
RelatedMemoIDList
{
placeholders
[
i
]
=
placeholder
(
len
(
args
)
+
1
)
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"related_memo_id IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
find
.
MemoFilter
!=
nil
{
engine
,
err
:=
filter
.
DefaultEngine
()
...
...
store/db/sqlite/memo_relation.go
View file @
50638040
...
...
@@ -56,12 +56,27 @@ func (d *DB) ListMemoRelations(ctx context.Context, find *store.FindMemoRelation
args
=
append
(
args
,
id
)
}
inClause
:=
strings
.
Join
(
placeholders
,
", "
)
// Duplicate args for the second IN clause.
for
_
,
id
:=
range
find
.
MemoIDList
{
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"(memo_id IN (%s) OR related_memo_id IN (%s))"
,
inClause
,
inClause
))
}
if
len
(
find
.
SourceMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
SourceMemoIDList
))
for
i
,
id
:=
range
find
.
SourceMemoIDList
{
placeholders
[
i
]
=
"?"
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"memo_id IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
len
(
find
.
RelatedMemoIDList
)
>
0
{
placeholders
:=
make
([]
string
,
len
(
find
.
RelatedMemoIDList
))
for
i
,
id
:=
range
find
.
RelatedMemoIDList
{
placeholders
[
i
]
=
"?"
args
=
append
(
args
,
id
)
}
where
=
append
(
where
,
fmt
.
Sprintf
(
"related_memo_id IN (%s)"
,
strings
.
Join
(
placeholders
,
", "
)))
}
if
find
.
MemoFilter
!=
nil
{
engine
,
err
:=
filter
.
DefaultEngine
()
if
err
!=
nil
{
...
...
store/memo_relation.go
View file @
50638040
...
...
@@ -26,6 +26,10 @@ type FindMemoRelation struct {
MemoFilter
*
string
// MemoIDList matches relations where memo_id OR related_memo_id is in the list.
MemoIDList
[]
int32
// SourceMemoIDList matches relations where memo_id is in the list.
SourceMemoIDList
[]
int32
// RelatedMemoIDList matches relations where related_memo_id is in the list.
RelatedMemoIDList
[]
int32
Limit
*
int
Offset
*
int
}
...
...
web/src/components/MemoMetadata/Relation/RelationListView.tsx
View file @
50638040
...
...
@@ -5,6 +5,7 @@ import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
RelationCard
from
"./RelationCard"
;
import
{
getRelationBuckets
,
getRelationMemo
,
getRelationMemoName
,
type
RelationDirection
}
from
"./relationHelpers"
;
import
{
useResolvedRelationMemos
}
from
"./useResolvedRelationMemos"
;
interface
RelationListViewProps
{
relations
:
MemoRelation
[];
...
...
@@ -16,6 +17,7 @@ interface RelationListViewProps {
function
RelationListView
({
relations
,
currentMemoName
,
parentPage
,
className
}:
RelationListViewProps
)
{
const
t
=
useTranslate
();
const
[
activeTab
,
setActiveTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
resolvedMemos
=
useResolvedRelationMemos
(
relations
);
const
{
referencing
:
referencingRelations
,
referenced
:
referencedRelations
}
=
useMemo
(
()
=>
getRelationBuckets
(
relations
,
currentMemoName
),
...
...
@@ -60,9 +62,15 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
}
contentClassName=
"flex flex-col gap-0 p-1.5"
>
{
activeRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
getRelationMemoName
(
relation
,
direction
)
}
memo=
{
getRelationMemo
(
relation
,
direction
)
!
}
parentPage=
{
parentPage
}
/>
))
}
{
activeRelations
.
map
((
relation
)
=>
{
const
memo
=
getRelationMemo
(
relation
,
direction
);
if
(
!
memo
)
{
return
null
;
}
return
(
<
RelationCard
key=
{
getRelationMemoName
(
relation
,
direction
)
}
memo=
{
resolvedMemos
[
memo
.
name
]
??
memo
}
parentPage=
{
parentPage
}
/>
);
})
}
</
MetadataSection
>
);
}
...
...
web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts
View file @
50638040
...
...
@@ -11,9 +11,10 @@ export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
const
names
=
new
Set
<
string
>
();
for
(
const
relation
of
relations
)
{
const
relatedMemo
=
relation
.
relatedMemo
;
if
(
relatedMemo
?.
name
&&
!
relatedMemo
.
snippet
&&
!
resolvedMemos
[
relatedMemo
.
name
])
{
names
.
add
(
relatedMemo
.
name
);
for
(
const
memo
of
[
relation
.
memo
,
relation
.
relatedMemo
])
{
if
(
memo
?.
name
&&
!
memo
.
snippet
&&
!
resolvedMemos
[
memo
.
name
])
{
names
.
add
(
memo
.
name
);
}
}
}
...
...
web/src/components/MemoView/components/MemoCommentListView.tsx
View file @
50638040
...
...
@@ -10,7 +10,7 @@ const MemoCommentListView: React.FC = () => {
const
{
memo
}
=
useMemoViewContext
();
const
{
isInMemoDetailPage
,
commentAmount
}
=
useMemoViewDerived
();
const
{
data
}
=
useMemoComments
(
memo
.
name
,
{
enabled
:
!
isInMemoDetailPage
&&
commentAmount
>
0
});
const
{
data
}
=
useMemoComments
(
memo
.
name
,
{
enabled
:
!
isInMemoDetailPage
&&
commentAmount
>
0
,
pageSize
:
3
});
const
comments
=
data
?.
memos
??
[];
const
displayedComments
=
comments
.
slice
(
0
,
3
);
const
{
data
:
commentCreators
}
=
useUsersByNames
(
displayedComments
.
map
((
comment
)
=>
comment
.
creator
));
...
...
web/src/hooks/useMemoQueries.ts
View file @
50638040
...
...
@@ -5,7 +5,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
userKeys
}
from
"@/hooks/useUserQueries"
;
import
type
{
ListMemosRequest
,
ListMemosResponse
,
Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
ListMemosRequestSchema
,
MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
ListMemo
CommentsRequestSchema
,
ListMemo
sRequestSchema
,
MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
// Query keys factory for consistent cache management
export
const
memoKeys
=
{
...
...
@@ -243,11 +243,16 @@ export function useDeleteMemo() {
});
}
export
function
useMemoComments
(
name
:
string
,
options
?:
{
enabled
?:
boolean
})
{
export
function
useMemoComments
(
name
:
string
,
options
?:
{
enabled
?:
boolean
;
pageSize
?:
number
})
{
return
useQuery
({
queryKey
:
memoKeys
.
comments
(
name
)
,
queryKey
:
[...
memoKeys
.
comments
(
name
),
options
?.
pageSize
??
0
]
,
queryFn
:
async
()
=>
{
const
response
=
await
memoServiceClient
.
listMemoComments
({
name
});
const
response
=
await
memoServiceClient
.
listMemoComments
(
create
(
ListMemoCommentsRequestSchema
,
{
name
,
pageSize
:
options
?.
pageSize
??
0
,
}),
);
return
response
;
},
enabled
:
options
?.
enabled
??
true
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment